An ATproto social media client -- with an independent Appview.

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>

authored by

Eric Bailey
Samuel Newman
Paul Frazee
and committed by
GitHub
0ac15920 f1e44ee1

+2332 -395
+1
.eslintrc.js
··· 34 34 'P', 35 35 'Admonition', 36 36 'Admonition.Admonition', 37 + 'Span', 37 38 ], 38 39 impliedTextProps: [], 39 40 suggestedTextWrappers: {
+1
assets/icons/circleCheck_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/sparkle_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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.6 7.6 0 0 1-1.43 2.178A7.3 7.3 0 0 1 8.349 12.5a7.3 7.3 0 0 1 2.22 1.588A7.6 7.6 0 0 1 12 16.267a7.6 7.6 0 0 1 1.43-2.179 7.3 7.3 0 0 1 2.221-1.588 7.3 7.3 0 0 1-2.22-1.588A7.6 7.6 0 0 1 12 8.734Z" clip-rule="evenodd"/></svg>
+1
assets/icons/verifiedCheck.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#208BFE"/><path fill="#fff" fill-rule="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" clip-rule="evenodd"/></svg>
+1
assets/icons/verifierCheck.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#208BFE" d="M8.792 1.54a4.11 4.11 0 0 1 6.416 0 4.13 4.13 0 0 0 3.146 1.54c2.616.04 4.544 2.5 4 5.1a4.28 4.28 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.1 4.1 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.28 4.28 0 0 0 .777-3.46c-.544-2.602 1.384-5.06 4-5.1a4.13 4.13 0 0 0 3.146-1.54Z"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.399a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 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.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg>
assets/images/initial_verification_announcement_1.png

This is a binary file and will not be displayed.

assets/images/initial_verification_announcement_2.png

This is a binary file and will not be displayed.

+1
bskyweb/cmd/bskyweb/server.go
··· 263 263 e.GET("/moderation/modlists", server.WebGeneric) 264 264 e.GET("/moderation/muted-accounts", server.WebGeneric) 265 265 e.GET("/moderation/blocked-accounts", server.WebGeneric) 266 + e.GET("/moderation/verification-settings", server.WebGeneric) 266 267 e.GET("/settings", server.WebGeneric) 267 268 e.GET("/settings/language", server.WebGeneric) 268 269 e.GET("/settings/app-passwords", server.WebGeneric)
+1 -1
package.json
··· 58 58 "icons:optimize": "svgo -f ./assets/icons" 59 59 }, 60 60 "dependencies": { 61 - "@atproto/api": "^0.14.21", 61 + "@atproto/api": "^0.15.3", 62 62 "@bitdrift/react-native": "^0.6.8", 63 63 "@braintree/sanitize-url": "^6.0.2", 64 64 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+9
src/Navigation.tsx
··· 70 70 import {MessagesInboxScreen} from '#/screens/Messages/Inbox' 71 71 import {MessagesSettingsScreen} from '#/screens/Messages/Settings' 72 72 import {ModerationScreen} from '#/screens/Moderation' 73 + import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings' 73 74 import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' 74 75 import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' 75 76 import {PostQuotesScreen} from '#/screens/Post/PostQuotes' ··· 164 165 getComponent={() => ModerationInteractionSettings} 165 166 options={{ 166 167 title: title(msg`Post Interaction Settings`), 168 + requireAuth: true, 169 + }} 170 + /> 171 + <Stack.Screen 172 + name="ModerationVerificationSettings" 173 + getComponent={() => ModerationVerificationSettings} 174 + options={{ 175 + title: title(msg`Verification Settings`), 167 176 requireAuth: true, 168 177 }} 169 178 />
+42 -25
src/components/AccountList.tsx
··· 1 1 import React, {useCallback} from 'react' 2 2 import {View} from 'react-native' 3 - import {AppBskyActorDefs} from '@atproto/api' 3 + import {type AppBskyActorDefs} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 ··· 12 12 import {atoms as a, useTheme} from '#/alf' 13 13 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 14 14 import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' 15 + import {useSimpleVerificationState} from '#/components/verification' 16 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 15 17 import {Button} from './Button' 16 18 import {Text} from './Typography' 17 19 ··· 74 76 ]}> 75 77 <Text 76 78 style={[ 77 - a.align_baseline, 79 + a.font_bold, 78 80 a.flex_1, 79 81 a.flex_row, 80 82 a.py_sm, 81 - {paddingLeft: 48}, 83 + a.leading_tight, 84 + t.atoms.text_contrast_medium, 85 + {paddingLeft: 56}, 82 86 ]}> 83 87 {otherLabel ?? <Trans>Other account</Trans>} 84 88 </Text> ··· 105 109 }) { 106 110 const t = useTheme() 107 111 const {_} = useLingui() 112 + const verification = useSimpleVerificationState({profile}) 108 113 109 114 const onPress = useCallback(() => { 110 115 onSelect(account) ··· 114 119 <Button 115 120 testID={`chooseAccountBtn-${account.handle}`} 116 121 key={account.did} 117 - style={[a.flex_1]} 122 + style={[a.w_full]} 118 123 onPress={onPress} 119 124 label={ 120 125 isCurrentAccount ··· 127 132 a.flex_1, 128 133 a.flex_row, 129 134 a.align_center, 130 - {height: 48}, 135 + a.px_md, 136 + a.gap_sm, 137 + {height: 56}, 131 138 (hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25, 132 139 ]}> 133 - <View style={a.p_md}> 134 - <UserAvatar 135 - avatar={profile?.avatar} 136 - size={24} 137 - type={profile?.associated?.labeler ? 'labeler' : 'user'} 138 - /> 139 - </View> 140 - <Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}> 141 - <Text emoji style={[a.font_bold]}> 142 - {sanitizeDisplayName( 143 - profile?.displayName || profile?.handle || account.handle, 140 + <UserAvatar 141 + avatar={profile?.avatar} 142 + size={36} 143 + type={profile?.associated?.labeler ? 'labeler' : 'user'} 144 + /> 145 + 146 + <View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}> 147 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 148 + <Text 149 + emoji 150 + style={[a.font_bold, a.leading_tight]} 151 + numberOfLines={1}> 152 + {sanitizeDisplayName( 153 + profile?.displayName || profile?.handle || account.handle, 154 + )} 155 + </Text> 156 + {verification.showBadge && ( 157 + <View> 158 + <VerificationCheck 159 + width={12} 160 + verifier={verification.role === 'verifier'} 161 + /> 162 + </View> 144 163 )} 145 - </Text>{' '} 146 - <Text emoji style={[t.atoms.text_contrast_medium]}> 147 - {sanitizeHandle(account.handle)} 164 + </View> 165 + <Text style={[a.leading_tight, t.atoms.text_contrast_medium]}> 166 + {sanitizeHandle(account.handle, '@')} 148 167 </Text> 149 - </Text> 168 + </View> 169 + 150 170 {isCurrentAccount ? ( 151 - <Check 152 - size="sm" 153 - style={[{color: t.palette.positive_600}, a.mr_md]} 154 - /> 171 + <Check size="sm" style={[{color: t.palette.positive_600}]} /> 155 172 ) : ( 156 - <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> 173 + <Chevron size="sm" style={[t.atoms.text]} /> 157 174 )} 158 175 </View> 159 176 )}
+5 -1
src/components/Link.tsx
··· 210 210 } 211 211 212 212 export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> & 213 - Omit<ButtonProps, 'onPress' | 'disabled'> 213 + Omit<ButtonProps, 'onPress' | 'disabled'> & { 214 + overridePresentation?: boolean 215 + } 214 216 215 217 /** 216 218 * A interactive element that renders as a `<a>` tag on the web. On mobile it ··· 228 230 onLongPress: outerOnLongPress, 229 231 download, 230 232 shouldProxy, 233 + overridePresentation, 231 234 ...rest 232 235 }: LinkProps) { 233 236 const {href, isExternal, onPress, onLongPress} = useLink({ ··· 237 240 onPress: outerOnPress, 238 241 onLongPress: outerOnLongPress, 239 242 shouldProxy: shouldProxy, 243 + overridePresentation, 240 244 }) 241 245 242 246 return (
+19 -6
src/components/ProfileCard.tsx
··· 30 30 import * as Pills from '#/components/Pills' 31 31 import {RichText} from '#/components/RichText' 32 32 import {Text} from '#/components/Typography' 33 + import {useSimpleVerificationState} from '#/components/verification' 34 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 33 35 import type * as bsky from '#/types/bsky' 34 36 35 37 export function Default({ ··· 186 188 profile.displayName || sanitizeHandle(profile.handle), 187 189 moderation.ui('displayName'), 188 190 ) 191 + const verification = useSimpleVerificationState({profile}) 189 192 return ( 190 - <Text 191 - emoji 192 - style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} 193 - numberOfLines={1}> 194 - {name} 195 - </Text> 193 + <View style={[a.flex_row, a.align_center]}> 194 + <Text 195 + emoji 196 + style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} 197 + numberOfLines={1}> 198 + {name} 199 + </Text> 200 + {verification.showBadge && ( 201 + <View style={[a.pl_xs]}> 202 + <VerificationCheck 203 + width={14} 204 + verifier={verification.role === 'verifier'} 205 + /> 206 + </View> 207 + )} 208 + </View> 196 209 ) 197 210 } 198 211
+32 -8
src/components/ProfileHoverCard/index.web.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 3 + import { 4 + type AppBskyActorDefs, 5 + moderateProfile, 6 + type ModerationOpts, 7 + } from '@atproto/api' 4 8 import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' 5 9 import {msg, plural} from '@lingui/macro' 6 10 import {useLingui} from '@lingui/react' ··· 33 37 import {Portal} from '#/components/Portal' 34 38 import {RichText} from '#/components/RichText' 35 39 import {Text} from '#/components/Typography' 36 - import {ProfileHoverCardProps} from './types' 40 + import {useSimpleVerificationState} from '#/components/verification' 41 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 42 + import {type ProfileHoverCardProps} from './types' 37 43 38 44 const floatingMiddlewares = [ 39 45 offset(4), ··· 412 418 [currentAccount, profile], 413 419 ) 414 420 const isLabeler = profile.associated?.labeler 421 + const verification = useSimpleVerificationState({profile}) 415 422 416 423 return ( 417 424 <View> ··· 465 472 466 473 <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> 467 474 <View style={[a.pb_sm, a.flex_1]}> 468 - <Text 469 - style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}> 470 - {sanitizeDisplayName( 471 - profile.displayName || sanitizeHandle(profile.handle), 472 - moderation.ui('displayName'), 475 + <View style={[a.flex_row, a.align_center, a.pt_md, a.pb_xs]}> 476 + <Text 477 + numberOfLines={1} 478 + style={[a.text_lg, a.font_bold, a.self_start]}> 479 + {sanitizeDisplayName( 480 + profile.displayName || sanitizeHandle(profile.handle), 481 + moderation.ui('displayName'), 482 + )} 483 + </Text> 484 + {verification.showBadge && ( 485 + <View 486 + style={[ 487 + a.pl_xs, 488 + { 489 + marginTop: -2, 490 + }, 491 + ]}> 492 + <VerificationCheck 493 + width={16} 494 + verifier={verification.role === 'verifier'} 495 + /> 496 + </View> 473 497 )} 474 - </Text> 498 + </View> 475 499 476 500 <ProfileHeaderHandle profile={profileShadow} disableTaps /> 477 501 </View>
+18 -8
src/components/Prompt.tsx
··· 1 1 import React from 'react' 2 - import {GestureResponderEvent, View} from 'react-native' 2 + import {type GestureResponderEvent, View} from 'react-native' 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 7 - import {Button, ButtonColor, ButtonText} from '#/components/Button' 6 + import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 7 + import {Button, type ButtonColor, ButtonText} from '#/components/Button' 8 8 import * as Dialog from '#/components/Dialog' 9 9 import {Text} from '#/components/Typography' 10 - import {BottomSheetViewProps} from '../../modules/bottom-sheet' 10 + import {type BottomSheetViewProps} from '../../modules/bottom-sheet' 11 11 12 12 export { 13 13 type DialogControlProps as PromptControlProps, ··· 62 62 ) 63 63 } 64 64 65 - export function TitleText({children}: React.PropsWithChildren<{}>) { 65 + export function TitleText({ 66 + children, 67 + style, 68 + }: React.PropsWithChildren<ViewStyleProp>) { 66 69 const {titleId} = React.useContext(Context) 67 70 return ( 68 71 <Text 69 72 nativeID={titleId} 70 - style={[a.text_2xl, a.font_bold, a.pb_sm, a.leading_snug]}> 73 + style={[ 74 + a.flex_1, 75 + a.text_2xl, 76 + a.font_bold, 77 + a.pb_sm, 78 + a.leading_snug, 79 + style, 80 + ]}> 71 81 {children} 72 82 </Text> 73 83 ) ··· 190 200 }: React.PropsWithChildren<{ 191 201 control: Dialog.DialogOuterProps['control'] 192 202 title: string 193 - description: string 203 + description?: string 194 204 cancelButtonCta?: string 195 205 confirmButtonCta?: string 196 206 /** ··· 207 217 return ( 208 218 <Outer control={control} testID="confirmModal"> 209 219 <TitleText>{title}</TitleText> 210 - <DescriptionText>{description}</DescriptionText> 220 + {description && <DescriptionText>{description}</DescriptionText>} 211 221 <Actions> 212 222 <Action 213 223 cta={confirmButtonCta}
+3 -1
src/components/Typography.tsx
··· 6 6 childHasEmoji, 7 7 normalizeTextStyles, 8 8 renderChildrenWithEmoji, 9 - TextProps, 9 + type TextProps, 10 10 } from '#/alf/typography' 11 + 11 12 export type {TextProps} 13 + export {Text as Span} from 'react-native' 12 14 13 15 /** 14 16 * Our main text component. Use this most of the time.
+194
src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {urls} from '#/lib/constants' 8 + import {logger} from '#/logger' 9 + import {isNative} from '#/platform/detection' 10 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 11 + import {Button, ButtonText} from '#/components/Button' 12 + import * as Dialog from '#/components/Dialog' 13 + import {useNuxDialogContext} from '#/components/dialogs/nuxs' 14 + import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' 15 + import {VerifierCheck} from '#/components/icons/VerifierCheck' 16 + import {Link} from '#/components/Link' 17 + import {Span, Text} from '#/components/Typography' 18 + 19 + export function InitialVerificationAnnouncement() { 20 + const t = useTheme() 21 + const {_} = useLingui() 22 + const {gtMobile} = useBreakpoints() 23 + const nuxDialogs = useNuxDialogContext() 24 + const control = Dialog.useDialogControl() 25 + 26 + Dialog.useAutoOpen(control) 27 + 28 + const onClose = useCallback(() => { 29 + nuxDialogs.dismissActiveNux() 30 + }, [nuxDialogs]) 31 + 32 + return ( 33 + <Dialog.Outer control={control} onClose={onClose}> 34 + <Dialog.Handle /> 35 + 36 + <Dialog.ScrollableInner 37 + label={_(msg`Announcing verification on Bluesky`)} 38 + style={[ 39 + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, 40 + ]}> 41 + <View style={[a.align_start, a.gap_xl]}> 42 + <View 43 + style={[ 44 + a.pl_sm, 45 + a.pr_md, 46 + a.py_sm, 47 + a.rounded_full, 48 + a.flex_row, 49 + a.align_center, 50 + a.gap_xs, 51 + { 52 + backgroundColor: t.palette.primary_25, 53 + }, 54 + ]}> 55 + <SparkleIcon fill={t.palette.primary_700} size="sm" /> 56 + <Text 57 + style={[ 58 + a.font_bold, 59 + { 60 + color: t.palette.primary_700, 61 + }, 62 + ]}> 63 + <Trans>New Feature</Trans> 64 + </Text> 65 + </View> 66 + 67 + <View 68 + style={[ 69 + a.w_full, 70 + a.rounded_md, 71 + a.overflow_hidden, 72 + t.atoms.bg_contrast_25, 73 + {minHeight: 100}, 74 + ]}> 75 + <Image 76 + accessibilityIgnoresInvertColors 77 + source={require('../../../../assets/images/initial_verification_announcement_1.png')} 78 + style={[ 79 + { 80 + aspectRatio: 353 / 160, 81 + }, 82 + ]} 83 + alt={_( 84 + msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, 85 + )} 86 + /> 87 + </View> 88 + 89 + <View style={[a.gap_xs]}> 90 + <Text style={[a.text_2xl, a.font_bold, a.leading_snug]}> 91 + <Trans>A new form of verification</Trans> 92 + </Text> 93 + <Text style={[a.leading_snug, a.text_md]}> 94 + <Trans> 95 + We’re introducing a new layer of verification on Bluesky — an 96 + easy-to-see checkmark. 97 + </Trans> 98 + </Text> 99 + </View> 100 + 101 + <View 102 + style={[ 103 + a.w_full, 104 + a.rounded_md, 105 + a.overflow_hidden, 106 + t.atoms.bg_contrast_25, 107 + {minHeight: 100}, 108 + ]}> 109 + <Image 110 + accessibilityIgnoresInvertColors 111 + source={require('../../../../assets/images/initial_verification_announcement_2.png')} 112 + style={[ 113 + { 114 + aspectRatio: 353 / 196, 115 + }, 116 + ]} 117 + alt={_( 118 + 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.`, 119 + )} 120 + /> 121 + </View> 122 + 123 + <View style={[a.gap_sm]}> 124 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 125 + <VerifierCheck width={14} /> 126 + <Text style={[a.text_lg, a.font_bold, a.leading_snug]}> 127 + <Trans>Who can verify?</Trans> 128 + </Text> 129 + </View> 130 + <View style={[a.gap_sm]}> 131 + <Text style={[a.leading_snug, a.text_md]}> 132 + <Trans> 133 + Bluesky will proactively verify notable and authentic 134 + accounts. 135 + </Trans> 136 + </Text> 137 + <Text style={[a.leading_snug, a.text_md]}> 138 + <Trans> 139 + Trust emerges from relationships, communities, and shared 140 + context, so we’re also enabling{' '} 141 + <Span style={[a.font_bold]}>trusted verifiers</Span>: 142 + organizations that can directly issue verification. 143 + </Trans> 144 + </Text> 145 + <Text style={[a.leading_snug, a.text_md]}> 146 + <Trans> 147 + When you tap on a check, you’ll see which organizations have 148 + granted verification. 149 + </Trans> 150 + </Text> 151 + </View> 152 + </View> 153 + 154 + <View style={[a.w_full, a.gap_md]}> 155 + <Link 156 + overridePresentation 157 + to={urls.website.blog.initialVerificationAnnouncement} 158 + label={_(msg`Read blog post`)} 159 + size="small" 160 + variant="solid" 161 + color="primary" 162 + style={[a.justify_center, a.w_full]} 163 + onPress={() => { 164 + logger.metric('verification:learn-more', { 165 + location: 'initialAnnouncementeNux', 166 + }) 167 + }}> 168 + <ButtonText> 169 + <Trans>Read blog post</Trans> 170 + </ButtonText> 171 + </Link> 172 + {isNative && ( 173 + <Button 174 + label={_(msg`Close`)} 175 + size="small" 176 + variant="solid" 177 + color="secondary" 178 + style={[a.justify_center, a.w_full]} 179 + onPress={() => { 180 + control.close() 181 + }}> 182 + <ButtonText> 183 + <Trans>Close</Trans> 184 + </ButtonText> 185 + </Button> 186 + )} 187 + </View> 188 + </View> 189 + 190 + <Dialog.Close /> 191 + </Dialog.ScrollableInner> 192 + </Dialog.Outer> 193 + ) 194 + }
+13 -4
src/components/dialogs/nuxs/index.tsx
··· 1 1 import React from 'react' 2 - import {AppBskyActorDefs} from '@atproto/api' 2 + import {type AppBskyActorDefs} from '@atproto/api' 3 3 4 4 import {useGate} from '#/lib/statsig/statsig' 5 5 import {logger} from '#/logger' 6 6 import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' 7 7 import { 8 8 usePreferencesQuery, 9 - UsePreferencesQueryResponse, 9 + type UsePreferencesQueryResponse, 10 10 } from '#/state/queries/preferences' 11 11 import {useProfileQuery} from '#/state/queries/profile' 12 - import {SessionAccount, useSession} from '#/state/session' 12 + import {type SessionAccount, useSession} from '#/state/session' 13 13 import {useOnboardingState} from '#/state/shell' 14 + import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement' 14 15 /* 15 16 * NUXs 16 17 */ ··· 29 30 currentProfile: AppBskyActorDefs.ProfileViewDetailed 30 31 preferences: UsePreferencesQueryResponse 31 32 }) => boolean 32 - }[] = [] 33 + }[] = [ 34 + { 35 + id: Nux.InitialVerificationAnnouncement, 36 + enabled: () => true, 37 + }, 38 + ] 33 39 34 40 const Context = React.createContext<Context>({ 35 41 activeNux: undefined, ··· 163 169 return ( 164 170 <Context.Provider value={ctx}> 165 171 {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} 172 + {activeNux === Nux.InitialVerificationAnnouncement && ( 173 + <InitialVerificationAnnouncement /> 174 + )} 166 175 </Context.Provider> 167 176 ) 168 177 }
+32 -17
src/components/dms/MessagesListHeader.tsx
··· 1 1 import React, {useCallback} from 'react' 2 2 import {TouchableOpacity, View} from 'react-native' 3 3 import { 4 - AppBskyActorDefs, 5 - ModerationCause, 6 - ModerationDecision, 4 + type AppBskyActorDefs, 5 + type ModerationCause, 6 + type ModerationDecision, 7 7 } from '@atproto/api' 8 8 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 9 9 import {msg} from '@lingui/macro' ··· 12 12 13 13 import {BACK_HITSLOP} from '#/lib/constants' 14 14 import {makeProfileLink} from '#/lib/routes/links' 15 - import {NavigationProp} from '#/lib/routes/types' 15 + import {type NavigationProp} from '#/lib/routes/types' 16 16 import {sanitizeDisplayName} from '#/lib/strings/display-names' 17 17 import {isWeb} from '#/platform/detection' 18 - import {Shadow} from '#/state/cache/profile-shadow' 18 + import {type Shadow} from '#/state/cache/profile-shadow' 19 19 import {isConvoActive, useConvo} from '#/state/messages/convo' 20 - import {ConvoItem} from '#/state/messages/convo/types' 20 + import {type ConvoItem} from '#/state/messages/convo/types' 21 21 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 22 22 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 23 23 import {ConvoMenu} from '#/components/dms/ConvoMenu' ··· 25 25 import {Link} from '#/components/Link' 26 26 import {PostAlerts} from '#/components/moderation/PostAlerts' 27 27 import {Text} from '#/components/Typography' 28 + import {useSimpleVerificationState} from '#/components/verification' 29 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 28 30 29 31 const PFP_SIZE = isWeb ? 40 : 34 30 32 ··· 149 151 const {_} = useLingui() 150 152 const t = useTheme() 151 153 const convoState = useConvo() 154 + const verification = useSimpleVerificationState({ 155 + profile, 156 + }) 152 157 153 158 const isDeletedAccount = profile?.handle === 'missing.invalid' 154 159 const displayName = isDeletedAccount ··· 185 190 /> 186 191 </View> 187 192 <View style={a.flex_1}> 188 - <Text 189 - emoji 190 - style={[ 191 - a.text_md, 192 - a.font_bold, 193 - a.self_start, 194 - web(a.leading_normal), 195 - ]} 196 - numberOfLines={1}> 197 - {displayName} 198 - </Text> 193 + <View style={[a.flex_row, a.align_center]}> 194 + <Text 195 + emoji 196 + style={[ 197 + a.text_md, 198 + a.font_bold, 199 + a.self_start, 200 + web(a.leading_normal), 201 + ]} 202 + numberOfLines={1}> 203 + {displayName} 204 + </Text> 205 + {verification.showBadge && ( 206 + <View style={[a.pl_xs]}> 207 + <VerificationCheck 208 + width={14} 209 + verifier={verification.role === 'verifier'} 210 + /> 211 + </View> 212 + )} 213 + </View> 199 214 {!isDeletedAccount && ( 200 215 <Text 201 216 style={[
+5
src/components/icons/CircleCheck.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const CircleCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/Sparkle.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Sparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+30
src/components/icons/VerifiedCheck.tsx
··· 1 + import React from 'react' 2 + import Svg, {Circle, Path} from 'react-native-svg' 3 + 4 + import {type Props, useCommonSVGProps} from '#/components/icons/common' 5 + 6 + export const VerifiedCheck = React.forwardRef<Svg, Props>(function LogoImpl( 7 + props, 8 + ref, 9 + ) { 10 + const {fill, size, style, ...rest} = useCommonSVGProps(props) 11 + 12 + return ( 13 + <Svg 14 + fill="none" 15 + {...rest} 16 + ref={ref} 17 + viewBox="0 0 24 24" 18 + width={size} 19 + height={size} 20 + style={[style]}> 21 + <Circle cx="12" cy="12" r="12" fill={fill} /> 22 + <Path 23 + fill="#fff" 24 + fillRule="evenodd" 25 + clipRule="evenodd" 26 + 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" 27 + /> 28 + </Svg> 29 + ) 30 + })
+35
src/components/icons/VerifierCheck.tsx
··· 1 + import React from 'react' 2 + import Svg, {Path} from 'react-native-svg' 3 + 4 + import {type Props, useCommonSVGProps} from '#/components/icons/common' 5 + 6 + export const VerifierCheck = React.forwardRef<Svg, Props>(function LogoImpl( 7 + props, 8 + ref, 9 + ) { 10 + const {fill, size, style, ...rest} = useCommonSVGProps(props) 11 + 12 + return ( 13 + <Svg 14 + fill="none" 15 + {...rest} 16 + ref={ref} 17 + viewBox="0 0 24 24" 18 + width={size} 19 + height={size} 20 + style={[style]}> 21 + <Path 22 + fill={fill} 23 + fillRule="evenodd" 24 + clipRule="evenodd" 25 + 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" 26 + /> 27 + <Path 28 + fill="#fff" 29 + fillRule="evenodd" 30 + clipRule="evenodd" 31 + 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" 32 + /> 33 + </Svg> 34 + ) 35 + })
+12
src/components/verification/VerificationCheck.tsx
··· 1 + import {type Props} from '#/components/icons/common' 2 + import {VerifiedCheck} from '#/components/icons/VerifiedCheck' 3 + import {VerifierCheck} from '#/components/icons/VerifierCheck' 4 + 5 + export function VerificationCheck({ 6 + verifier, 7 + ...rest 8 + }: Props & { 9 + verifier?: boolean 10 + }) { 11 + return verifier ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} /> 12 + }
+155
src/components/verification/VerificationCheckButton.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {logger} from '#/logger' 6 + import {type Shadow} from '#/state/cache/types' 7 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 8 + import {Button} from '#/components/Button' 9 + import {useDialogControl} from '#/components/Dialog' 10 + import {useFullVerificationState} from '#/components/verification' 11 + import {type FullVerificationState} from '#/components/verification' 12 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 13 + import {VerificationsDialog} from '#/components/verification/VerificationsDialog' 14 + import {VerifierDialog} from '#/components/verification/VerifierDialog' 15 + import type * as bsky from '#/types/bsky' 16 + 17 + export function shouldShowVerificationCheckButton( 18 + state: FullVerificationState, 19 + ) { 20 + let ok = false 21 + 22 + if (state.profile.role === 'default') { 23 + if (state.profile.isVerified) { 24 + ok = true 25 + } else if (state.profile.isViewer && state.profile.wasVerified) { 26 + ok = true 27 + } else if ( 28 + state.viewer.role === 'verifier' && 29 + state.viewer.hasIssuedVerification 30 + ) { 31 + ok = true 32 + } 33 + } else if (state.profile.role === 'verifier') { 34 + if (state.profile.isViewer) { 35 + ok = true 36 + } else if (state.profile.isVerified) { 37 + ok = true 38 + } 39 + } 40 + 41 + if ( 42 + !state.profile.showBadge && 43 + !state.profile.isViewer && 44 + !(state.viewer.role === 'verifier' && state.viewer.hasIssuedVerification) 45 + ) { 46 + ok = false 47 + } 48 + 49 + return ok 50 + } 51 + 52 + export function VerificationCheckButton({ 53 + profile, 54 + size, 55 + }: { 56 + profile: Shadow<bsky.profile.AnyProfileView> 57 + size: 'lg' | 'md' | 'sm' 58 + }) { 59 + const state = useFullVerificationState({ 60 + profile, 61 + }) 62 + 63 + if (shouldShowVerificationCheckButton(state)) { 64 + return <Badge profile={profile} verificationState={state} size={size} /> 65 + } 66 + 67 + return null 68 + } 69 + 70 + export function Badge({ 71 + profile, 72 + verificationState: state, 73 + size, 74 + }: { 75 + profile: Shadow<bsky.profile.AnyProfileView> 76 + verificationState: FullVerificationState 77 + size: 'lg' | 'md' | 'sm' 78 + }) { 79 + const t = useTheme() 80 + const {_} = useLingui() 81 + const verificationsDialogControl = useDialogControl() 82 + const verifierDialogControl = useDialogControl() 83 + const {gtPhone} = useBreakpoints() 84 + let dimensions = 12 85 + if (size === 'lg') { 86 + dimensions = gtPhone ? 20 : 18 87 + } else if (size === 'md') { 88 + dimensions = 16 89 + } 90 + 91 + const verifiedByHidden = !state.profile.showBadge && state.profile.isViewer 92 + 93 + return ( 94 + <> 95 + <Button 96 + label={ 97 + state.profile.isViewer 98 + ? _(msg`View your verifications`) 99 + : _(msg`View this user's verifications`) 100 + } 101 + hitSlop={20} 102 + onPress={() => { 103 + logger.metric('verification:badge:click', {}) 104 + if (state.profile.role === 'verifier') { 105 + verifierDialogControl.open() 106 + } else { 107 + verificationsDialogControl.open() 108 + } 109 + }} 110 + style={[]}> 111 + {({hovered}) => ( 112 + <View 113 + style={[ 114 + a.justify_end, 115 + a.align_end, 116 + a.transition_transform, 117 + { 118 + width: dimensions, 119 + height: dimensions, 120 + transform: [ 121 + { 122 + scale: hovered ? 1.1 : 1, 123 + }, 124 + ], 125 + }, 126 + ]}> 127 + <VerificationCheck 128 + width={dimensions} 129 + fill={ 130 + verifiedByHidden 131 + ? t.atoms.bg_contrast_100.backgroundColor 132 + : state.profile.isVerified 133 + ? t.palette.primary_500 134 + : t.atoms.bg_contrast_100.backgroundColor 135 + } 136 + verifier={state.profile.role === 'verifier'} 137 + /> 138 + </View> 139 + )} 140 + </Button> 141 + 142 + <VerificationsDialog 143 + control={verificationsDialogControl} 144 + profile={profile} 145 + verificationState={state} 146 + /> 147 + 148 + <VerifierDialog 149 + control={verifierDialogControl} 150 + profile={profile} 151 + verificationState={state} 152 + /> 153 + </> 154 + ) 155 + }
+70
src/components/verification/VerificationCreatePrompt.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {logger} from '#/logger' 7 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 + import {useVerificationCreateMutation} from '#/state/queries/verification/useVerificationCreateMutation' 9 + import * as Toast from '#/view/com/util/Toast' 10 + import {atoms as a} from '#/alf' 11 + import {type DialogControlProps} from '#/components/Dialog' 12 + import {VerifiedCheck} from '#/components/icons/VerifiedCheck' 13 + import * as ProfileCard from '#/components/ProfileCard' 14 + import * as Prompt from '#/components/Prompt' 15 + import type * as bsky from '#/types/bsky' 16 + 17 + export function VerificationCreatePrompt({ 18 + control, 19 + profile, 20 + }: { 21 + control: DialogControlProps 22 + profile: bsky.profile.AnyProfileView 23 + }) { 24 + const {_} = useLingui() 25 + const moderationOpts = useModerationOpts() 26 + const {mutateAsync: create} = useVerificationCreateMutation() 27 + const onConfirm = useCallback(async () => { 28 + try { 29 + await create({profile}) 30 + Toast.show(_(msg`Successfully verified`)) 31 + } catch (e) { 32 + Toast.show(_(msg`Failed to create a verification`), 'xmark') 33 + logger.error('Failed to create a verification', { 34 + safeMessage: e, 35 + }) 36 + } 37 + }, [_, profile, create]) 38 + 39 + return ( 40 + <Prompt.Outer control={control}> 41 + <View style={[a.flex_row, a.align_center, a.gap_sm, a.pb_sm]}> 42 + <VerifiedCheck width={18} /> 43 + <Prompt.TitleText style={[a.pb_0]}> 44 + {_(msg`Verify this account?`)} 45 + </Prompt.TitleText> 46 + </View> 47 + <Prompt.DescriptionText> 48 + {_(msg`This action can be undone at any time.`)} 49 + </Prompt.DescriptionText> 50 + <View style={[a.pb_xl]}> 51 + {moderationOpts ? ( 52 + <ProfileCard.Header> 53 + <ProfileCard.Avatar 54 + profile={profile} 55 + moderationOpts={moderationOpts} 56 + /> 57 + <ProfileCard.NameAndHandle 58 + profile={profile} 59 + moderationOpts={moderationOpts} 60 + /> 61 + </ProfileCard.Header> 62 + ) : null} 63 + </View> 64 + <Prompt.Actions> 65 + <Prompt.Action cta={_(msg`Verify account`)} onPress={onConfirm} /> 66 + <Prompt.Cancel /> 67 + </Prompt.Actions> 68 + </Prompt.Outer> 69 + ) 70 + }
+50
src/components/verification/VerificationRemovePrompt.tsx
··· 1 + import {useCallback} from 'react' 2 + import {type AppBskyActorDefs} from '@atproto/api' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {logger} from '#/logger' 7 + import {useVerificationsRemoveMutation} from '#/state/queries/verification/useVerificationsRemoveMutation' 8 + import * as Toast from '#/view/com/util/Toast' 9 + import {type DialogControlProps} from '#/components/Dialog' 10 + import * as Prompt from '#/components/Prompt' 11 + import type * as bsky from '#/types/bsky' 12 + 13 + export {useDialogControl as usePromptControl} from '#/components/Dialog' 14 + 15 + export function VerificationRemovePrompt({ 16 + control, 17 + profile, 18 + verifications, 19 + onConfirm: onConfirmInner, 20 + }: { 21 + control: DialogControlProps 22 + profile: bsky.profile.AnyProfileView 23 + verifications: AppBskyActorDefs.VerificationView[] 24 + onConfirm?: () => void 25 + }) { 26 + const {_} = useLingui() 27 + const {mutateAsync: remove} = useVerificationsRemoveMutation() 28 + const onConfirm = useCallback(async () => { 29 + onConfirmInner?.() 30 + try { 31 + await remove({profile, verifications}) 32 + Toast.show(_(msg`Removed verification`)) 33 + } catch (e) { 34 + Toast.show(_(msg`Failed to remove verification`), 'xmark') 35 + logger.error('Failed to remove verification', { 36 + safeMessage: e, 37 + }) 38 + } 39 + }, [_, profile, verifications, remove, onConfirmInner]) 40 + 41 + return ( 42 + <Prompt.Basic 43 + control={control} 44 + title={_(msg`Remove your verification for this account?`)} 45 + onConfirm={onConfirm} 46 + confirmButtonCta={_(msg`Remove verification`)} 47 + confirmButtonColor="negative" 48 + /> 49 + ) 50 + }
+257
src/components/verification/VerificationsDialog.tsx
··· 1 + import {View} from 'react-native' 2 + import {type AppBskyActorDefs} from '@atproto/api' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {urls} from '#/lib/constants' 7 + import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 + import {logger} from '#/logger' 9 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 10 + import {useProfileQuery} from '#/state/queries/profile' 11 + import {useSession} from '#/state/session' 12 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 13 + import {Admonition} from '#/components/Admonition' 14 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 + import * as Dialog from '#/components/Dialog' 16 + import {useDialogControl} from '#/components/Dialog' 17 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 18 + import {Link} from '#/components/Link' 19 + import * as ProfileCard from '#/components/ProfileCard' 20 + import {Text} from '#/components/Typography' 21 + import {type FullVerificationState} from '#/components/verification' 22 + import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 23 + import type * as bsky from '#/types/bsky' 24 + 25 + export {useDialogControl} from '#/components/Dialog' 26 + 27 + export function VerificationsDialog({ 28 + control, 29 + profile, 30 + verificationState, 31 + }: { 32 + control: Dialog.DialogControlProps 33 + profile: bsky.profile.AnyProfileView 34 + verificationState: FullVerificationState 35 + }) { 36 + return ( 37 + <Dialog.Outer control={control}> 38 + <Inner 39 + control={control} 40 + profile={profile} 41 + verificationState={verificationState} 42 + /> 43 + <Dialog.Close /> 44 + </Dialog.Outer> 45 + ) 46 + } 47 + 48 + function Inner({ 49 + profile, 50 + control, 51 + verificationState: state, 52 + }: { 53 + control: Dialog.DialogControlProps 54 + profile: bsky.profile.AnyProfileView 55 + verificationState: FullVerificationState 56 + }) { 57 + const t = useTheme() 58 + const {_} = useLingui() 59 + const {gtMobile} = useBreakpoints() 60 + 61 + const userName = getUserDisplayName(profile) 62 + const label = state.profile.isViewer 63 + ? state.profile.isVerified 64 + ? _(msg`You are verified`) 65 + : _(msg`Your verifications`) 66 + : state.profile.isVerified 67 + ? _(msg`${userName} is verified`) 68 + : _( 69 + msg({ 70 + message: `${userName}'s verifications`, 71 + comment: `Possessive, meaning "the verifications of {userName}"`, 72 + }), 73 + ) 74 + 75 + return ( 76 + <Dialog.ScrollableInner 77 + label={label} 78 + style={[ 79 + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, 80 + ]}> 81 + <Dialog.Handle /> 82 + 83 + <View style={[a.gap_sm, a.pb_lg]}> 84 + <Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}> 85 + {label} 86 + </Text> 87 + <Text style={[a.text_md, a.leading_snug]}> 88 + {state.profile.isVerified ? ( 89 + <Trans> 90 + This account has a checkmark because it's been verified by trusted 91 + sources. 92 + </Trans> 93 + ) : ( 94 + <Trans> 95 + This account has one or more verifications, but it is not 96 + currently verified. 97 + </Trans> 98 + )} 99 + </Text> 100 + </View> 101 + 102 + {profile.verification ? ( 103 + <View style={[a.pb_xl, a.gap_md]}> 104 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 105 + <Trans>Verified by:</Trans> 106 + </Text> 107 + 108 + <View style={[a.gap_lg]}> 109 + {profile.verification.verifications.map(v => ( 110 + <VerifierCard 111 + key={v.uri} 112 + verification={v} 113 + subject={profile} 114 + outerDialogControl={control} 115 + /> 116 + ))} 117 + </View> 118 + 119 + {profile.verification.verifications.some(v => !v.isValid) && 120 + state.profile.isViewer && ( 121 + <Admonition type="warning" style={[a.mt_xs]}> 122 + <Trans>Some of your verifications are invalid.</Trans> 123 + </Admonition> 124 + )} 125 + </View> 126 + ) : null} 127 + 128 + <View 129 + style={[ 130 + a.w_full, 131 + a.gap_sm, 132 + a.justify_end, 133 + gtMobile 134 + ? [a.flex_row, a.flex_row_reverse, a.justify_start] 135 + : [a.flex_col], 136 + ]}> 137 + <Button 138 + label={_(msg`Close dialog`)} 139 + size="small" 140 + variant="solid" 141 + color="primary" 142 + onPress={() => { 143 + control.close() 144 + }}> 145 + <ButtonText> 146 + <Trans>Close</Trans> 147 + </ButtonText> 148 + </Button> 149 + <Link 150 + overridePresentation 151 + to={urls.website.blog.initialVerificationAnnouncement} 152 + label={_(msg`Learn more about verification on Bluesky`)} 153 + size="small" 154 + variant="solid" 155 + color="secondary" 156 + style={[a.justify_center]} 157 + onPress={() => { 158 + logger.metric('verification:learn-more', { 159 + location: 'verificationsDialog', 160 + }) 161 + }}> 162 + <ButtonText> 163 + <Trans>Learn more</Trans> 164 + </ButtonText> 165 + </Link> 166 + </View> 167 + 168 + <Dialog.Close /> 169 + </Dialog.ScrollableInner> 170 + ) 171 + } 172 + 173 + function VerifierCard({ 174 + verification, 175 + subject, 176 + outerDialogControl, 177 + }: { 178 + verification: AppBskyActorDefs.VerificationView 179 + subject: bsky.profile.AnyProfileView 180 + outerDialogControl: Dialog.DialogControlProps 181 + }) { 182 + const t = useTheme() 183 + const {_} = useLingui() 184 + const {currentAccount} = useSession() 185 + const moderationOpts = useModerationOpts() 186 + const {data: profile, error} = useProfileQuery({did: verification.issuer}) 187 + const verificationRemovePromptControl = useDialogControl() 188 + const canAdminister = verification.issuer === currentAccount?.did 189 + 190 + return ( 191 + <View 192 + style={{ 193 + opacity: verification.isValid ? 1 : 0.5, 194 + }}> 195 + <ProfileCard.Outer> 196 + <ProfileCard.Header> 197 + {error ? ( 198 + <> 199 + <ProfileCard.AvatarPlaceholder /> 200 + <View style={[a.flex_1]}> 201 + <Text 202 + style={[a.text_md, a.font_bold, a.leading_snug]} 203 + numberOfLines={1}> 204 + <Trans>Unknown verifier</Trans> 205 + </Text> 206 + <Text 207 + emoji 208 + style={[a.leading_snug, t.atoms.text_contrast_medium]} 209 + numberOfLines={1}> 210 + {verification.issuer} 211 + </Text> 212 + </View> 213 + </> 214 + ) : profile && moderationOpts ? ( 215 + <> 216 + <ProfileCard.Avatar 217 + profile={profile} 218 + moderationOpts={moderationOpts} 219 + /> 220 + <ProfileCard.NameAndHandle 221 + profile={profile} 222 + moderationOpts={moderationOpts} 223 + /> 224 + {canAdminister && ( 225 + <View> 226 + <Button 227 + label={_(msg`Remove verification`)} 228 + size="small" 229 + variant="outline" 230 + color="negative" 231 + shape="round" 232 + onPress={() => { 233 + verificationRemovePromptControl.open() 234 + }}> 235 + <ButtonIcon icon={TrashIcon} /> 236 + </Button> 237 + </View> 238 + )} 239 + </> 240 + ) : ( 241 + <> 242 + <ProfileCard.AvatarPlaceholder /> 243 + <ProfileCard.NameAndHandlePlaceholder /> 244 + </> 245 + )} 246 + </ProfileCard.Header> 247 + </ProfileCard.Outer> 248 + 249 + <VerificationRemovePrompt 250 + control={verificationRemovePromptControl} 251 + profile={subject} 252 + verifications={[verification]} 253 + onConfirm={() => outerDialogControl.close()} 254 + /> 255 + </View> 256 + ) 257 + }
+153
src/components/verification/VerifierDialog.tsx
··· 1 + import {Text as RNText, View} from 'react-native' 2 + import {Image} from 'expo-image' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {urls} from '#/lib/constants' 7 + import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 + import {NON_BREAKING_SPACE} from '#/lib/strings/constants' 9 + import {logger} from '#/logger' 10 + import {useSession} from '#/state/session' 11 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 + import {Button, ButtonText} from '#/components/Button' 13 + import * as Dialog from '#/components/Dialog' 14 + import {VerifierCheck} from '#/components/icons/VerifierCheck' 15 + import {Link} from '#/components/Link' 16 + import {Text} from '#/components/Typography' 17 + import {type FullVerificationState} from '#/components/verification' 18 + import type * as bsky from '#/types/bsky' 19 + 20 + export {useDialogControl} from '#/components/Dialog' 21 + 22 + export function VerifierDialog({ 23 + control, 24 + profile, 25 + verificationState, 26 + }: { 27 + control: Dialog.DialogControlProps 28 + profile: bsky.profile.AnyProfileView 29 + verificationState: FullVerificationState 30 + }) { 31 + return ( 32 + <Dialog.Outer control={control}> 33 + <Inner 34 + control={control} 35 + profile={profile} 36 + verificationState={verificationState} 37 + /> 38 + <Dialog.Close /> 39 + </Dialog.Outer> 40 + ) 41 + } 42 + 43 + function Inner({ 44 + profile, 45 + control, 46 + }: { 47 + control: Dialog.DialogControlProps 48 + profile: bsky.profile.AnyProfileView 49 + verificationState: FullVerificationState 50 + }) { 51 + const t = useTheme() 52 + const {_} = useLingui() 53 + const {gtMobile} = useBreakpoints() 54 + const {currentAccount} = useSession() 55 + 56 + const isSelf = profile.did === currentAccount?.did 57 + const userName = getUserDisplayName(profile) 58 + const label = isSelf 59 + ? _(msg`You are a trusted verifier`) 60 + : _(msg`${userName} is a trusted verifier`) 61 + 62 + return ( 63 + <Dialog.ScrollableInner 64 + label={label} 65 + style={[ 66 + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, 67 + ]}> 68 + <Dialog.Handle /> 69 + 70 + <View style={[a.gap_lg]}> 71 + <View 72 + style={[ 73 + a.w_full, 74 + a.rounded_md, 75 + a.overflow_hidden, 76 + t.atoms.bg_contrast_25, 77 + {minHeight: 100}, 78 + ]}> 79 + <Image 80 + accessibilityIgnoresInvertColors 81 + source={require('../../../assets/images/initial_verification_announcement_1.png')} 82 + style={[ 83 + { 84 + aspectRatio: 353 / 160, 85 + }, 86 + ]} 87 + alt={_( 88 + msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, 89 + )} 90 + /> 91 + </View> 92 + 93 + <View style={[a.gap_sm]}> 94 + <Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}> 95 + {label} 96 + </Text> 97 + <Text style={[a.text_md, a.leading_snug]}> 98 + <Trans> 99 + Accounts with a scalloped blue check mark 100 + <RNText> 101 + {NON_BREAKING_SPACE} 102 + <VerifierCheck width={14} /> 103 + {NON_BREAKING_SPACE} 104 + </RNText> 105 + can verify others. These trusted verifiers are selected by 106 + Bluesky. 107 + </Trans> 108 + </Text> 109 + </View> 110 + 111 + <View 112 + style={[ 113 + a.w_full, 114 + a.gap_sm, 115 + a.justify_end, 116 + gtMobile ? [a.flex_row, a.justify_end] : [a.flex_col], 117 + ]}> 118 + <Link 119 + overridePresentation 120 + to={urls.website.blog.initialVerificationAnnouncement} 121 + label={_(msg`Learn more about verification on Bluesky`)} 122 + size="small" 123 + variant="solid" 124 + color="primary" 125 + style={[a.justify_center]} 126 + onPress={() => { 127 + logger.metric('verification:learn-more', { 128 + location: 'verifierDialog', 129 + }) 130 + }}> 131 + <ButtonText> 132 + <Trans>Learn more</Trans> 133 + </ButtonText> 134 + </Link> 135 + <Button 136 + label={_(msg`Close dialog`)} 137 + size="small" 138 + variant="solid" 139 + color="secondary" 140 + onPress={() => { 141 + control.close() 142 + }}> 143 + <ButtonText> 144 + <Trans>Close</Trans> 145 + </ButtonText> 146 + </Button> 147 + </View> 148 + </View> 149 + 150 + <Dialog.Close /> 151 + </Dialog.ScrollableInner> 152 + ) 153 + }
+113
src/components/verification/index.ts
··· 1 + import {useMemo} from 'react' 2 + 3 + import {usePreferencesQuery} from '#/state/queries/preferences' 4 + import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 5 + import {useSession} from '#/state/session' 6 + import type * as bsky from '#/types/bsky' 7 + 8 + export type FullVerificationState = { 9 + profile: { 10 + role: 'default' | 'verifier' 11 + isVerified: boolean 12 + wasVerified: boolean 13 + isViewer: boolean 14 + showBadge: boolean 15 + } 16 + viewer: 17 + | { 18 + role: 'default' 19 + isVerified: boolean 20 + } 21 + | { 22 + role: 'verifier' 23 + isVerified: boolean 24 + hasIssuedVerification: boolean 25 + } 26 + } 27 + 28 + export function useFullVerificationState({ 29 + profile, 30 + }: { 31 + profile: bsky.profile.AnyProfileView 32 + }): FullVerificationState { 33 + const {currentAccount} = useSession() 34 + const currentAccountProfile = useCurrentAccountProfile() 35 + const profileState = useSimpleVerificationState({profile}) 36 + const viewerState = useSimpleVerificationState({ 37 + profile: currentAccountProfile, 38 + }) 39 + 40 + return useMemo(() => { 41 + const verifications = profile.verification?.verifications || [] 42 + const wasVerified = 43 + profileState.role === 'default' && 44 + !profileState.isVerified && 45 + verifications.length > 0 46 + const hasIssuedVerification = Boolean( 47 + viewerState && 48 + viewerState.role === 'verifier' && 49 + profileState.role === 'default' && 50 + verifications.find(v => v.issuer === currentAccount?.did), 51 + ) 52 + 53 + return { 54 + profile: { 55 + ...profileState, 56 + wasVerified, 57 + isViewer: profile.did === currentAccount?.did, 58 + showBadge: profileState.showBadge, 59 + }, 60 + viewer: 61 + viewerState.role === 'verifier' 62 + ? { 63 + role: 'verifier', 64 + isVerified: viewerState.isVerified, 65 + hasIssuedVerification, 66 + } 67 + : { 68 + role: 'default', 69 + isVerified: viewerState.isVerified, 70 + }, 71 + } 72 + }, [profile, currentAccount, profileState, viewerState]) 73 + } 74 + 75 + export type SimpleVerificationState = { 76 + role: 'default' | 'verifier' 77 + isVerified: boolean 78 + showBadge: boolean 79 + } 80 + 81 + export function useSimpleVerificationState({ 82 + profile, 83 + }: { 84 + profile?: bsky.profile.AnyProfileView 85 + }): SimpleVerificationState { 86 + const preferences = usePreferencesQuery() 87 + const prefs = useMemo( 88 + () => preferences.data?.verificationPrefs || {hideBadges: false}, 89 + [preferences.data?.verificationPrefs], 90 + ) 91 + return useMemo(() => { 92 + if (!profile || !profile.verification) { 93 + return { 94 + role: 'default', 95 + isVerified: false, 96 + showBadge: false, 97 + } 98 + } 99 + 100 + const {verifiedStatus, trustedVerifierStatus} = profile.verification 101 + const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus) 102 + const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus) 103 + const isVerified = 104 + (isVerifiedUser && verifiedStatus === 'valid') || 105 + (isVerifierUser && trustedVerifierStatus === 'valid') 106 + 107 + return { 108 + role: isVerifierUser ? 'verifier' : 'default', 109 + isVerified, 110 + showBadge: prefs.hideBadges ? false : isVerified, 111 + } 112 + }, [profile, prefs]) 113 + }
+8
src/lib/constants.ts
··· 192 192 export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number] 193 193 194 194 export const EMOJI_REACTION_LIMIT = 5 195 + 196 + export const urls = { 197 + website: { 198 + blog: { 199 + initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`, 200 + }, 201 + }, 202 + }
+10
src/lib/getUserDisplayName.ts
··· 1 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 2 + import {sanitizeHandle} from '#/lib/strings/handles' 3 + 4 + export function getUserDisplayName< 5 + T extends {displayName?: string; handle: string; [key: string]: any}, 6 + >(props: T): string { 7 + return sanitizeDisplayName( 8 + props.displayName || sanitizeHandle(props.handle, '@'), 9 + ) 10 + }
+1
src/lib/routes/types.ts
··· 13 13 ModerationMutedAccounts: undefined 14 14 ModerationBlockedAccounts: undefined 15 15 ModerationInteractionSettings: undefined 16 + ModerationVerificationSettings: undefined 16 17 Settings: undefined 17 18 Profile: {name: string; hideBackButton?: boolean} 18 19 ProfileFollowers: {name: string}
+13
src/logger/metrics.ts
··· 370 370 targetLanguage: string 371 371 textLength: number 372 372 } 373 + 374 + 'verification:create': {} 375 + 'verification:revoke': {} 376 + 'verification:badge:click': {} 377 + 'verification:learn-more': { 378 + location: 379 + | 'initialAnnouncementeNux' 380 + | 'verificationsDialog' 381 + | 'verifierDialog' 382 + | 'verificationSettings' 383 + } 384 + 'verification:settings:hideBadges': {} 385 + 'verification:settings:unHideBadges': {} 373 386 }
+1
src/routes.ts
··· 14 14 ModerationMutedAccounts: '/moderation/muted-accounts', 15 15 ModerationBlockedAccounts: '/moderation/blocked-accounts', 16 16 ModerationInteractionSettings: '/moderation/interaction-settings', 17 + ModerationVerificationSettings: '/moderation/verification-settings', 17 18 // profiles, threads, lists 18 19 Profile: ['/profile/:name', '/profile/:name/rss'], 19 20 ProfileFollowers: '/profile/:name/followers',
+31 -18
src/screens/Messages/components/ChatListItem.tsx
··· 43 43 import {useMenuControl} from '#/components/Menu' 44 44 import {PostAlerts} from '#/components/moderation/PostAlerts' 45 45 import {Text} from '#/components/Typography' 46 + import {useSimpleVerificationState} from '#/components/verification' 47 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 46 48 import type * as bsky from '#/types/bsky' 47 49 48 50 export let ChatListItem = ({ ··· 106 108 const playHaptic = useHaptics() 107 109 const queryClient = useQueryClient() 108 110 const isUnread = convo.unreadCount > 0 111 + const verification = useSimpleVerificationState({ 112 + profile, 113 + }) 109 114 110 115 const blockInfo = useMemo(() => { 111 116 const modui = moderation.ui('profileView') ··· 385 390 <View 386 391 style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}> 387 392 <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}> 388 - <Text 389 - numberOfLines={1} 390 - style={[{maxWidth: '85%'}, web([a.leading_normal])]}> 393 + <View style={[a.flex_shrink]}> 391 394 <Text 392 395 emoji 396 + numberOfLines={1} 393 397 style={[ 394 398 a.text_md, 395 399 t.atoms.text, ··· 399 403 ]}> 400 404 {displayName} 401 405 </Text> 402 - </Text> 406 + </View> 407 + {verification.showBadge && ( 408 + <View style={[a.pl_xs, a.self_center]}> 409 + <VerificationCheck 410 + width={14} 411 + verifier={verification.role === 'verifier'} 412 + /> 413 + </View> 414 + )} 403 415 {lastMessageSentAt && ( 404 - <TimeElapsed timestamp={lastMessageSentAt}> 405 - {({timeElapsed}) => ( 406 - <Text 407 - style={[ 408 - a.text_sm, 409 - {lineHeight: 21}, 410 - t.atoms.text_contrast_medium, 411 - web({whiteSpace: 'preserve nowrap'}), 412 - ]}> 413 - {' '} 414 - &middot; {timeElapsed} 415 - </Text> 416 - )} 417 - </TimeElapsed> 416 + <View style={[a.pl_xs]}> 417 + <TimeElapsed timestamp={lastMessageSentAt}> 418 + {({timeElapsed}) => ( 419 + <Text 420 + style={[ 421 + a.text_sm, 422 + {lineHeight: 21}, 423 + t.atoms.text_contrast_medium, 424 + web({whiteSpace: 'preserve nowrap'}), 425 + ]}> 426 + &middot; {timeElapsed} 427 + </Text> 428 + )} 429 + </TimeElapsed> 430 + </View> 418 431 )} 419 432 {(convo.muted || moderation.blocked) && ( 420 433 <Text
+96
src/screens/Moderation/VerificationSettings.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {urls} from '#/lib/constants' 6 + import {logger} from '#/logger' 7 + import { 8 + usePreferencesQuery, 9 + type UsePreferencesQueryResponse, 10 + } from '#/state/queries/preferences' 11 + import {useSetVerificationPrefsMutation} from '#/state/queries/preferences' 12 + import * as SettingsList from '#/screens/Settings/components/SettingsList' 13 + import {atoms as a, useGutters} from '#/alf' 14 + import {Admonition} from '#/components/Admonition' 15 + import * as Toggle from '#/components/forms/Toggle' 16 + import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' 17 + import * as Layout from '#/components/Layout' 18 + import {InlineLinkText} from '#/components/Link' 19 + import {Loader} from '#/components/Loader' 20 + 21 + export function Screen() { 22 + const {_} = useLingui() 23 + const gutters = useGutters(['base']) 24 + const {data: preferences} = usePreferencesQuery() 25 + 26 + return ( 27 + <Layout.Screen testID="ModerationVerificationSettingsScreen"> 28 + <Layout.Header.Outer> 29 + <Layout.Header.BackButton /> 30 + <Layout.Header.Content> 31 + <Layout.Header.TitleText> 32 + <Trans>Verification Settings</Trans> 33 + </Layout.Header.TitleText> 34 + </Layout.Header.Content> 35 + <Layout.Header.Slot /> 36 + </Layout.Header.Outer> 37 + <Layout.Content> 38 + <SettingsList.Container> 39 + <SettingsList.Item> 40 + <Admonition type="tip" style={[a.flex_1]}> 41 + <Trans> 42 + Verifications on Bluesky work differently than on other 43 + platforms.{' '} 44 + <InlineLinkText 45 + overridePresentation 46 + to={urls.website.blog.initialVerificationAnnouncement} 47 + label={_(msg`Learn more`)} 48 + onPress={() => { 49 + logger.metric('verification:learn-more', { 50 + location: 'verificationSettings', 51 + }) 52 + }}> 53 + Learn more here. 54 + </InlineLinkText> 55 + </Trans> 56 + </Admonition> 57 + </SettingsList.Item> 58 + {preferences ? ( 59 + <Inner preferences={preferences} /> 60 + ) : ( 61 + <View style={[gutters, a.justify_center, a.align_center]}> 62 + <Loader size="xl" /> 63 + </View> 64 + )} 65 + </SettingsList.Container> 66 + </Layout.Content> 67 + </Layout.Screen> 68 + ) 69 + } 70 + 71 + function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { 72 + const {_} = useLingui() 73 + const {hideBadges} = preferences.verificationPrefs 74 + const {mutate: setVerificationPrefs, isPending} = 75 + useSetVerificationPrefsMutation() 76 + 77 + return ( 78 + <Toggle.Item 79 + type="checkbox" 80 + name="hideBadges" 81 + label={_(msg`Hide verification badges`)} 82 + value={hideBadges} 83 + disabled={isPending} 84 + onChange={value => { 85 + setVerificationPrefs({hideBadges: value}) 86 + }}> 87 + <SettingsList.Item> 88 + <SettingsList.ItemIcon icon={CircleCheck} /> 89 + <SettingsList.ItemText> 90 + <Trans>Hide verification badges</Trans> 91 + </SettingsList.ItemText> 92 + <Toggle.Platform /> 93 + </SettingsList.Item> 94 + </Toggle.Item> 95 + ) 96 + }
+23 -4
src/screens/Moderation/index.tsx
··· 6 6 import {useFocusEffect} from '@react-navigation/native' 7 7 8 8 import {getLabelingServiceTitle} from '#/lib/moderation' 9 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 9 + import { 10 + type CommonNavigatorParams, 11 + type NativeStackScreenProps, 12 + } from '#/lib/routes/types' 10 13 import {logger} from '#/logger' 11 14 import {isIOS} from '#/platform/detection' 12 15 import { 13 16 useMyLabelersQuery, 14 17 usePreferencesQuery, 15 - UsePreferencesQueryResponse, 18 + type UsePreferencesQueryResponse, 16 19 usePreferencesSetAdultContentMutation, 17 20 } from '#/state/queries/preferences' 18 21 import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' 19 22 import {useSetMinimalShellMode} from '#/state/shell' 20 23 import {ViewHeader} from '#/view/com/util/ViewHeader' 21 - import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf' 24 + import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 22 25 import {Button, ButtonText} from '#/components/Button' 23 26 import * as Dialog from '#/components/Dialog' 24 27 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' ··· 27 30 import * as Toggle from '#/components/forms/Toggle' 28 31 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 29 32 import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 30 - import {Props as SVGIconProps} from '#/components/icons/common' 33 + import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' 34 + import {type Props as SVGIconProps} from '#/components/icons/common' 31 35 import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig' 32 36 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 33 37 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' ··· 268 272 <SubItem 269 273 title={_(msg`Blocked accounts`)} 270 274 icon={CircleBanSign} 275 + style={[ 276 + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 277 + ]} 278 + /> 279 + )} 280 + </Link> 281 + <Divider /> 282 + <Link 283 + label={_(msg`Manage verification settings`)} 284 + testID="verificationSettingsBtn" 285 + to="/moderation/verification-settings"> 286 + {state => ( 287 + <SubItem 288 + title={_(msg`Verification settings`)} 289 + icon={CircleCheck} 271 290 style={[ 272 291 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 273 292 ]}
+25 -2
src/screens/Profile/Header/EditProfileDialog.tsx
··· 1 1 import {useCallback, useEffect, useState} from 'react' 2 2 import {Dimensions, View} from 'react-native' 3 - import {Image as RNImage} from 'react-native-image-crop-picker' 4 - import {AppBskyActorDefs} from '@atproto/api' 3 + import {type Image as RNImage} from 'react-native-image-crop-picker' 4 + import {type AppBskyActorDefs} from '@atproto/api' 5 5 import {msg, Plural, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 + import {urls} from '#/lib/constants' 8 9 import {compressIfNeeded} from '#/lib/media/manip' 9 10 import {cleanError} from '#/lib/strings/errors' 10 11 import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers' ··· 16 17 import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 17 18 import {UserBanner} from '#/view/com/util/UserBanner' 18 19 import {atoms as a, useTheme} from '#/alf' 20 + import {Admonition} from '#/components/Admonition' 19 21 import {Button, ButtonText} from '#/components/Button' 20 22 import * as Dialog from '#/components/Dialog' 21 23 import * as TextField from '#/components/forms/TextField' 24 + import {InlineLinkText} from '#/components/Link' 22 25 import * as Prompt from '#/components/Prompt' 26 + import {useSimpleVerificationState} from '#/components/verification' 23 27 24 28 const DISPLAY_NAME_MAX_GRAPHEMES = 64 25 29 const DESCRIPTION_MAX_GRAPHEMES = 256 ··· 102 106 const {_} = useLingui() 103 107 const t = useTheme() 104 108 const control = Dialog.useDialogContext() 109 + const verification = useSimpleVerificationState({ 110 + profile, 111 + }) 105 112 const { 106 113 mutateAsync: updateProfileMutation, 107 114 error: updateProfileError, ··· 341 348 </TextField.SuffixText> 342 349 )} 343 350 </View> 351 + 352 + {verification.isVerified && 353 + verification.role === 'default' && 354 + displayName !== initialDisplayName && ( 355 + <Admonition type="error"> 356 + <Trans> 357 + You are verified. You will lose your verification status if you 358 + change your display name.{' '} 359 + <InlineLinkText 360 + label={_(msg`Learn more`)} 361 + to={urls.website.blog.initialVerificationAnnouncement}> 362 + <Trans>Learn more.</Trans> 363 + </InlineLinkText> 364 + </Trans> 365 + </Admonition> 366 + )} 344 367 345 368 <View> 346 369 <TextField.LabelText>
+31 -3
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 10 10 import {useLingui} from '@lingui/react' 11 11 12 12 import {sanitizeDisplayName} from '#/lib/strings/display-names' 13 + import {sanitizeHandle} from '#/lib/strings/handles' 13 14 import {logger} from '#/logger' 14 15 import {isIOS, isWeb} from '#/platform/detection' 15 16 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 22 23 import {useRequireAuth, useSession} from '#/state/session' 23 24 import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 24 25 import * as Toast from '#/view/com/util/Toast' 25 - import {atoms as a} from '#/alf' 26 + import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' 26 27 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 27 28 import {useDialogControl} from '#/components/Dialog' 28 29 import {MessageProfileButton} from '#/components/dms/MessageProfileButton' ··· 33 34 } from '#/components/KnownFollowers' 34 35 import * as Prompt from '#/components/Prompt' 35 36 import {RichText} from '#/components/RichText' 36 - import {ProfileHeaderDisplayName} from './DisplayName' 37 + import {Text} from '#/components/Typography' 38 + import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 37 39 import {EditProfileDialog} from './EditProfileDialog' 38 40 import {ProfileHeaderHandle} from './Handle' 39 41 import {ProfileHeaderMetrics} from './Metrics' ··· 54 56 hideBackButton = false, 55 57 isPlaceholderProfile, 56 58 }: Props): React.ReactNode => { 59 + const t = useTheme() 60 + const {gtMobile} = useBreakpoints() 57 61 const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 58 62 useProfileShadow(profileUnshadowed) 59 63 const {currentAccount, hasSession} = useSession() ··· 238 242 <ProfileMenu profile={profile} /> 239 243 </View> 240 244 <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_sm]}> 241 - <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> 245 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 246 + <Text 247 + emoji 248 + testID="profileHeaderDisplayName" 249 + style={[ 250 + t.atoms.text, 251 + gtMobile ? a.text_4xl : a.text_3xl, 252 + a.self_start, 253 + a.font_heavy, 254 + ]}> 255 + {sanitizeDisplayName( 256 + profile.displayName || sanitizeHandle(profile.handle), 257 + moderation.ui('displayName'), 258 + )} 259 + <View 260 + style={[ 261 + a.pl_xs, 262 + { 263 + marginTop: platform({ios: 2}), 264 + }, 265 + ]}> 266 + <VerificationCheckButton profile={profile} size="lg" /> 267 + </View> 268 + </Text> 269 + </View> 242 270 <ProfileHeaderHandle profile={profile} /> 243 271 </View> 244 272 {!isPlaceholderProfile && !isBlockedUser && (
+1 -1
src/screens/Search/components/AutocompleteResults.tsx
··· 49 49 ? undefined 50 50 : `/search?q=${encodeURIComponent(searchText)}` 51 51 } 52 - style={{borderBottomWidth: 1}} 52 + style={a.border_b} 53 53 /> 54 54 {autocompleteData?.map(item => ( 55 55 <SearchProfileCard
+94 -42
src/screens/Search/components/SearchHistory.tsx
··· 1 1 import {Pressable, ScrollView, StyleSheet, View} from 'react-native' 2 + import {moderateProfile, type ModerationOpts} from '@atproto/api' 2 3 import {msg, Trans} from '@lingui/macro' 3 4 import {useLingui} from '@lingui/react' 4 5 5 6 import {createHitslop, HITSLOP_10} from '#/lib/constants' 6 7 import {makeProfileLink} from '#/lib/routes/links' 7 8 import {sanitizeDisplayName} from '#/lib/strings/display-names' 9 + import {sanitizeHandle} from '#/lib/strings/handles' 10 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 11 import {Link} from '#/view/com/util/Link' 9 12 import {UserAvatar} from '#/view/com/util/UserAvatar' 10 13 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 11 - import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' 14 + import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' 12 15 import {Button, ButtonIcon} from '#/components/Button' 13 16 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 14 17 import * as Layout from '#/components/Layout' 15 18 import {Text} from '#/components/Typography' 19 + import {useSimpleVerificationState} from '#/components/verification' 20 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 16 21 import type * as bsky from '#/types/bsky' 17 22 18 23 export function SearchHistory({ ··· 31 36 onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void 32 37 }) { 33 38 const {gtMobile} = useBreakpoints() 34 - const t = useTheme() 35 39 const {_} = useLingui() 40 + const moderationOpts = useModerationOpts() 36 41 37 42 return ( 38 43 <Layout.Content ··· 54 59 <ScrollView 55 60 horizontal 56 61 keyboardShouldPersistTaps="handled" 62 + showsHorizontalScrollIndicator={false} 57 63 style={[ 58 64 a.flex_row, 59 65 a.flex_nowrap, 60 66 {marginHorizontal: tokens.space._2xl * -1}, 61 67 ]} 62 68 contentContainerStyle={[a.px_2xl, a.border_0]}> 63 - {selectedProfiles.slice(0, 5).map((profile, index) => ( 64 - <View 65 - key={index} 66 - style={[ 67 - styles.profileItem, 68 - !gtMobile && styles.profileItemMobile, 69 - ]}> 70 - <Link 71 - href={makeProfileLink(profile)} 72 - title={profile.handle} 73 - asAnchor 74 - anchorNoUnderline 75 - onBeforePress={() => onProfileClick(profile)} 76 - style={[a.align_center, a.w_full]}> 77 - <UserAvatar 78 - avatar={profile.avatar} 79 - type={profile.associated?.labeler ? 'labeler' : 'user'} 80 - size={60} 69 + {moderationOpts && 70 + selectedProfiles 71 + .slice(0, 5) 72 + .map(profile => ( 73 + <RecentProfileItem 74 + key={profile.did} 75 + profile={profile} 76 + moderationOpts={moderationOpts} 77 + onPress={() => onProfileClick(profile)} 78 + onRemove={() => onRemoveProfileClick(profile)} 81 79 /> 82 - <Text 83 - emoji 84 - style={[a.text_xs, a.text_center, styles.profileName]} 85 - numberOfLines={1}> 86 - {sanitizeDisplayName( 87 - profile.displayName || profile.handle, 88 - )} 89 - </Text> 90 - </Link> 91 - <Pressable 92 - accessibilityRole="button" 93 - accessibilityLabel={_(msg`Remove profile`)} 94 - accessibilityHint={_( 95 - msg`Removes profile from search history`, 96 - )} 97 - onPress={() => onRemoveProfileClick(profile)} 98 - hitSlop={createHitslop(6)} 99 - style={styles.profileRemoveBtn}> 100 - <XIcon size="xs" style={t.atoms.text_contrast_low} /> 101 - </Pressable> 102 - </View> 103 - ))} 80 + ))} 104 81 </ScrollView> 105 82 </BlockDrawerGesture> 106 83 </View> ··· 131 108 )} 132 109 </View> 133 110 </Layout.Content> 111 + ) 112 + } 113 + 114 + function RecentProfileItem({ 115 + profile, 116 + moderationOpts, 117 + onPress, 118 + onRemove, 119 + }: { 120 + profile: bsky.profile.AnyProfileView 121 + moderationOpts: ModerationOpts 122 + onPress: () => void 123 + onRemove: () => void 124 + }) { 125 + const {_} = useLingui() 126 + const {gtMobile} = useBreakpoints() 127 + const t = useTheme() 128 + 129 + const moderation = moderateProfile(profile, moderationOpts) 130 + const name = sanitizeDisplayName( 131 + profile.displayName || sanitizeHandle(profile.handle), 132 + moderation.ui('displayName'), 133 + ) 134 + const verification = useSimpleVerificationState({profile}) 135 + 136 + return ( 137 + <View style={[styles.profileItem, !gtMobile && styles.profileItemMobile]}> 138 + <Link 139 + href={makeProfileLink(profile)} 140 + title={profile.handle} 141 + asAnchor 142 + anchorNoUnderline 143 + onBeforePress={onPress} 144 + style={[a.align_center, a.w_full]}> 145 + <UserAvatar 146 + avatar={profile.avatar} 147 + type={profile.associated?.labeler ? 'labeler' : 'user'} 148 + size={60} 149 + moderation={moderation.ui('avatar')} 150 + /> 151 + <View style={styles.profileName}> 152 + <View 153 + style={[ 154 + a.flex_row, 155 + a.align_center, 156 + a.justify_center, 157 + web([a.flex_1]), 158 + ]}> 159 + <Text 160 + emoji 161 + style={[a.text_xs, a.leading_snug, a.self_start]} 162 + numberOfLines={1}> 163 + {name} 164 + </Text> 165 + {verification.showBadge && ( 166 + <View style={[a.pl_xs]}> 167 + <VerificationCheck 168 + width={12} 169 + verifier={verification.role === 'verifier'} 170 + /> 171 + </View> 172 + )} 173 + </View> 174 + </View> 175 + </Link> 176 + <Pressable 177 + accessibilityRole="button" 178 + accessibilityLabel={_(msg`Remove profile`)} 179 + accessibilityHint={_(msg`Removes profile from search history`)} 180 + hitSlop={createHitslop(6)} 181 + style={styles.profileRemoveBtn} 182 + onPress={onRemove}> 183 + <XIcon size="xs" style={t.atoms.text_contrast_low} /> 184 + </Pressable> 185 + </View> 134 186 ) 135 187 } 136 188
+35 -14
src/screens/Settings/Settings.tsx
··· 29 29 import * as Toast from '#/view/com/util/Toast' 30 30 import {UserAvatar} from '#/view/com/util/UserAvatar' 31 31 import * as SettingsList from '#/screens/Settings/components/SettingsList' 32 - import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' 32 + import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' 33 33 import {AvatarStackWithFetch} from '#/components/AvatarStack' 34 34 import {useDialogControl} from '#/components/Dialog' 35 35 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' ··· 55 55 import * as Menu from '#/components/Menu' 56 56 import * as Prompt from '#/components/Prompt' 57 57 import {Text} from '#/components/Typography' 58 + import {useFullVerificationState} from '#/components/verification' 59 + import { 60 + shouldShowVerificationCheckButton, 61 + VerificationCheckButton, 62 + } from '#/components/verification/VerificationCheckButton' 58 63 59 64 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> 60 65 export function SettingsScreen({}: Props) { ··· 278 283 const {gtMobile} = useBreakpoints() 279 284 const shadow = useProfileShadow(profile) 280 285 const moderationOpts = useModerationOpts() 286 + const verificationState = useFullVerificationState({ 287 + profile: shadow, 288 + }) 281 289 282 290 if (!moderationOpts) return null 283 291 ··· 292 300 type={shadow.associated?.labeler ? 'labeler' : 'user'} 293 301 /> 294 302 295 - <Text 296 - emoji 297 - testID="profileHeaderDisplayName" 298 - style={[ 299 - a.pt_sm, 300 - t.atoms.text, 301 - gtMobile ? a.text_4xl : a.text_3xl, 302 - a.font_heavy, 303 - ]}> 304 - {sanitizeDisplayName( 305 - profile.displayName || sanitizeHandle(profile.handle), 306 - moderation.ui('displayName'), 303 + <View style={[a.flex_row, a.gap_xs, a.align_center]}> 304 + <Text 305 + emoji 306 + testID="profileHeaderDisplayName" 307 + numberOfLines={1} 308 + style={[ 309 + a.pt_sm, 310 + t.atoms.text, 311 + gtMobile ? a.text_4xl : a.text_3xl, 312 + a.font_heavy, 313 + ]}> 314 + {sanitizeDisplayName( 315 + profile.displayName || sanitizeHandle(profile.handle), 316 + moderation.ui('displayName'), 317 + )} 318 + </Text> 319 + {shouldShowVerificationCheckButton(verificationState) && ( 320 + <View 321 + style={[ 322 + { 323 + marginTop: platform({web: 8, ios: 8, android: 10}), 324 + }, 325 + ]}> 326 + <VerificationCheckButton profile={shadow} size="lg" /> 327 + </View> 307 328 )} 308 - </Text> 329 + </View> 309 330 <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 310 331 {sanitizeHandle(profile.handle, '@')} 311 332 </Text>
+21 -2
src/screens/Settings/components/ChangeHandleDialog.tsx
··· 10 10 SlideOutLeft, 11 11 SlideOutRight, 12 12 } from 'react-native-reanimated' 13 - import {ComAtprotoServerDescribeServer} from '@atproto/api' 13 + import {type ComAtprotoServerDescribeServer} from '@atproto/api' 14 14 import {msg, Trans} from '@lingui/macro' 15 15 import {useLingui} from '@lingui/react' 16 16 import {useMutation, useQueryClient} from '@tanstack/react-query' 17 17 18 - import {HITSLOP_10} from '#/lib/constants' 18 + import {HITSLOP_10, urls} from '#/lib/constants' 19 19 import {cleanError} from '#/lib/strings/errors' 20 20 import {createFullHandle, validateServiceHandle} from '#/lib/strings/handles' 21 21 import {sanitizeHandle} from '#/lib/strings/handles' 22 22 import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle' 23 23 import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' 24 24 import {useServiceQuery} from '#/state/queries/service' 25 + import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 25 26 import {useAgent, useSession} from '#/state/session' 26 27 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 27 28 import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' ··· 40 41 import {InlineLinkText} from '#/components/Link' 41 42 import {Loader} from '#/components/Loader' 42 43 import {Text} from '#/components/Typography' 44 + import {useSimpleVerificationState} from '#/components/verification' 43 45 import {CopyButton} from './CopyButton' 44 46 45 47 export function ChangeHandleDialog({ ··· 152 154 const control = Dialog.useDialogContext() 153 155 const {currentAccount} = useSession() 154 156 const queryClient = useQueryClient() 157 + const profile = useCurrentAccountProfile() 158 + const verification = useSimpleVerificationState({ 159 + profile, 160 + }) 155 161 156 162 const { 157 163 mutate: changeHandle, ··· 197 203 <Animated.View 198 204 layout={native(LinearTransition)} 199 205 style={[a.flex_1, a.gap_md]}> 206 + {verification.isVerified && verification.role === 'default' && ( 207 + <Admonition type="error"> 208 + <Trans> 209 + You are verified. You will lose your verification status if you 210 + change your handle.{' '} 211 + <InlineLinkText 212 + label={_(msg`Learn more`)} 213 + to={urls.website.blog.initialVerificationAnnouncement}> 214 + <Trans>Learn more.</Trans> 215 + </InlineLinkText> 216 + </Trans> 217 + </Admonition> 218 + )} 200 219 <View> 201 220 <TextField.LabelText> 202 221 <Trans>New handle</Trans>
+4
src/state/cache/profile-shadow.ts
··· 1 1 import {useEffect, useMemo, useState} from 'react' 2 + import {type AppBskyActorDefs} from '@atproto/api' 2 3 import {type QueryClient} from '@tanstack/react-query' 3 4 import EventEmitter from 'eventemitter3' 4 5 ··· 29 30 followingUri: string | undefined 30 31 muted: boolean | undefined 31 32 blockingUri: string | undefined 33 + verification: AppBskyActorDefs.VerificationState 32 34 } 33 35 34 36 const shadows: WeakMap< ··· 134 136 blocking: 135 137 'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking, 136 138 }, 139 + verification: 140 + 'verification' in shadow ? shadow.verification : profile.verification, 137 141 }) 138 142 } 139 143
+16 -10
src/state/queries/notifications/util.ts
··· 1 1 import { 2 - AppBskyFeedDefs, 2 + type AppBskyFeedDefs, 3 3 AppBskyFeedLike, 4 4 AppBskyFeedPost, 5 5 AppBskyFeedRepost, 6 - AppBskyGraphDefs, 6 + type AppBskyGraphDefs, 7 7 AppBskyGraphStarterpack, 8 - AppBskyNotificationListNotifications, 9 - BskyAgent, 8 + type AppBskyNotificationListNotifications, 9 + type BskyAgent, 10 10 moderateNotification, 11 - ModerationOpts, 11 + type ModerationOpts, 12 12 } from '@atproto/api' 13 - import {QueryClient} from '@tanstack/react-query' 13 + import {type QueryClient} from '@tanstack/react-query' 14 14 import chunk from 'lodash.chunk' 15 15 16 16 import {labelIsHideableOffense} from '#/lib/moderation' 17 17 import * as bsky from '#/types/bsky' 18 18 import {precacheProfile} from '../profile' 19 - import {FeedNotification, FeedPage, NotificationType} from './types' 19 + import { 20 + type FeedNotification, 21 + type FeedPage, 22 + type NotificationType, 23 + } from './types' 20 24 21 25 const GROUPABLE_REASONS = ['like', 'repost', 'follow'] 22 26 const MS_1HR = 1e3 * 60 * 60 ··· 155 159 const type = toKnownType(notif) 156 160 if (type !== 'starterpack-joined') { 157 161 groupedNotifs.push({ 158 - _reactKey: `notif-${notif.uri}`, 162 + _reactKey: `notif-${notif.uri}-${notif.reason}`, 159 163 type, 160 164 notification: notif, 161 165 subjectUri: getSubjectUri(type, notif), 162 166 }) 163 167 } else { 164 168 groupedNotifs.push({ 165 - _reactKey: `notif-${notif.uri}`, 169 + _reactKey: `notif-${notif.uri}-${notif.reason}`, 166 170 type: 'starterpack-joined', 167 171 notification: notif, 168 172 subjectUri: notif.uri, ··· 238 242 notif.reason === 'reply' || 239 243 notif.reason === 'quote' || 240 244 notif.reason === 'follow' || 241 - notif.reason === 'starterpack-joined' 245 + notif.reason === 'starterpack-joined' || 246 + notif.reason === 'verified' || 247 + notif.reason === 'unverified' 242 248 ) { 243 249 return notif.reason as NotificationType 244 250 }
+6
src/state/queries/nuxs/definitions.ts
··· 5 5 export enum Nux { 6 6 NeueTypography = 'NeueTypography', 7 7 ExploreInterestsCard = 'ExploreInterestsCard', 8 + InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', 8 9 } 9 10 10 11 export const nuxNames = new Set(Object.values(Nux)) ··· 18 19 id: Nux.ExploreInterestsCard 19 20 data: undefined 20 21 } 22 + | { 23 + id: Nux.InitialVerificationAnnouncement 24 + data: undefined 25 + } 21 26 > 22 27 23 28 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { 24 29 [Nux.NeueTypography]: undefined, 25 30 [Nux.ExploreInterestsCard]: undefined, 31 + [Nux.InitialVerificationAnnouncement]: undefined, 26 32 }
+5 -2
src/state/queries/preferences/const.ts
··· 1 1 import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' 2 2 import { 3 - ThreadViewPreferences, 4 - UsePreferencesQueryResponse, 3 + type ThreadViewPreferences, 4 + type UsePreferencesQueryResponse, 5 5 } from '#/state/queries/preferences/types' 6 6 7 7 export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = ··· 42 42 postInteractionSettings: { 43 43 threadgateAllowRules: undefined, 44 44 postgateEmbeddingRules: [], 45 + }, 46 + verificationPrefs: { 47 + hideBadges: false, 45 48 }, 46 49 }
+25 -5
src/state/queries/preferences/index.ts
··· 1 1 import { 2 - AppBskyActorDefs, 3 - BskyFeedViewPreference, 4 - LabelPreference, 2 + type AppBskyActorDefs, 3 + type BskyFeedViewPreference, 4 + type LabelPreference, 5 5 } from '@atproto/api' 6 6 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 7 7 ··· 16 16 DEFAULT_THREAD_VIEW_PREFS, 17 17 } from '#/state/queries/preferences/const' 18 18 import { 19 - ThreadViewPreferences, 20 - UsePreferencesQueryResponse, 19 + type ThreadViewPreferences, 20 + type UsePreferencesQueryResponse, 21 21 } from '#/state/queries/preferences/types' 22 22 import {useAgent} from '#/state/session' 23 23 import {saveLabelers} from '#/state/session/agent-config' ··· 407 407 }, 408 408 }) 409 409 } 410 + 411 + export function useSetVerificationPrefsMutation() { 412 + const queryClient = useQueryClient() 413 + const agent = useAgent() 414 + 415 + return useMutation<void, unknown, AppBskyActorDefs.VerificationPrefs>({ 416 + mutationFn: async prefs => { 417 + await agent.setVerificationPrefs(prefs) 418 + if (prefs.hideBadges) { 419 + logger.metric('verification:settings:hideBadges', {}) 420 + } else { 421 + logger.metric('verification:settings:unHideBadges', {}) 422 + } 423 + // triggers a refetch 424 + await queryClient.invalidateQueries({ 425 + queryKey: preferencesQueryKey, 426 + }) 427 + }, 428 + }) 429 + }
+17 -14
src/state/queries/profile.ts
··· 1 1 import {useCallback} from 'react' 2 - import {Image as RNImage} from 'react-native-image-crop-picker' 2 + import {type Image as RNImage} from 'react-native-image-crop-picker' 3 3 import { 4 - AppBskyActorDefs, 5 - AppBskyActorGetProfile, 6 - AppBskyActorGetProfiles, 7 - AppBskyActorProfile, 4 + type AppBskyActorDefs, 5 + type AppBskyActorGetProfile, 6 + type AppBskyActorGetProfiles, 7 + type AppBskyActorProfile, 8 8 AtUri, 9 - BskyAgent, 10 - ComAtprotoRepoUploadBlob, 11 - Un$Typed, 9 + type BskyAgent, 10 + type ComAtprotoRepoUploadBlob, 11 + type Un$Typed, 12 12 } from '@atproto/api' 13 13 import { 14 14 keepPreviousData, 15 - QueryClient, 15 + type QueryClient, 16 16 useMutation, 17 17 useQuery, 18 18 useQueryClient, ··· 21 21 import {uploadBlob} from '#/lib/api' 22 22 import {until} from '#/lib/async/until' 23 23 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 24 - import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig' 25 - import {Shadow} from '#/state/cache/types' 24 + import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig' 25 + import {type Shadow} from '#/state/cache/types' 26 26 import {STALE} from '#/state/queries' 27 27 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 28 28 import { 29 29 unstableCacheProfileView, 30 30 useUnstableProfileViewCache, 31 31 } from '#/state/queries/unstable-profile-cache' 32 + import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 32 33 import * as userActionHistory from '#/state/userActionHistory' 33 - import * as bsky from '#/types/bsky' 34 + import type * as bsky from '#/types/bsky' 34 35 import {updateProfileShadow} from '../cache/profile-shadow' 35 36 import {useAgent, useSession} from '../session' 36 37 import { ··· 50 51 const RQKEY_ROOT = 'profile' 51 52 export const RQKEY = (did: string) => [RQKEY_ROOT, did] 52 53 53 - const profilesQueryKeyRoot = 'profiles' 54 + export const profilesQueryKeyRoot = 'profiles' 54 55 export const profilesQueryKey = (handles: string[]) => [ 55 56 profilesQueryKeyRoot, 56 57 handles, ··· 137 138 export function useProfileUpdateMutation() { 138 139 const queryClient = useQueryClient() 139 140 const agent = useAgent() 141 + const updateProfileVerificationCache = useUpdateProfileVerificationCache() 140 142 return useMutation<void, Error, ProfileUpdateParams>({ 141 143 mutationFn: async ({ 142 144 profile, ··· 223 225 }), 224 226 ) 225 227 }, 226 - onSuccess(data, variables) { 228 + async onSuccess(_, variables) { 227 229 // invalidate cache 228 230 queryClient.invalidateQueries({ 229 231 queryKey: RQKEY(variables.profile.did), ··· 231 233 queryClient.invalidateQueries({ 232 234 queryKey: [profilesQueryKeyRoot, [variables.profile.did]], 233 235 }) 236 + await updateProfileVerificationCache({profile: variables.profile}) 234 237 }, 235 238 }) 236 239 }
+9
src/state/queries/useCurrentAccountProfile.tsx
··· 1 + import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' 2 + import {useProfileQuery} from '#/state/queries/profile' 3 + import {useSession} from '#/state/session' 4 + 5 + export function useCurrentAccountProfile() { 6 + const {currentAccount} = useSession() 7 + const {data: profile} = useProfileQuery({did: currentAccount?.did}) 8 + return useMaybeProfileShadow(profile) 9 + }
+35
src/state/queries/verification/useUpdateProfileVerificationCache.ts
··· 1 + import {useCallback} from 'react' 2 + import {useQueryClient} from '@tanstack/react-query' 3 + 4 + import {logger} from '#/logger' 5 + import {updateProfileShadow} from '#/state/cache/profile-shadow' 6 + import {useAgent} from '#/state/session' 7 + import type * as bsky from '#/types/bsky' 8 + 9 + /** 10 + * Fetches a fresh verification state from the app view and updates our profile 11 + * cache. This state is computed using a variety of factors on the server, so 12 + * we need to get this data from the server. 13 + */ 14 + export function useUpdateProfileVerificationCache() { 15 + const qc = useQueryClient() 16 + const agent = useAgent() 17 + 18 + return useCallback( 19 + async ({profile}: {profile: bsky.profile.AnyProfileView}) => { 20 + try { 21 + const {data: updated} = await agent.getProfile({ 22 + actor: profile.did ?? '', 23 + }) 24 + updateProfileShadow(qc, profile.did, { 25 + verification: updated.verification, 26 + }) 27 + } catch (e) { 28 + logger.error(`useUpdateProfileVerificationCache failed`, { 29 + safeMessage: e, 30 + }) 31 + } 32 + }, 33 + [agent, qc], 34 + ) 35 + }
+53
src/state/queries/verification/useVerificationCreateMutation.tsx
··· 1 + import {type AppBskyActorGetProfile} from '@atproto/api' 2 + import {useMutation} from '@tanstack/react-query' 3 + 4 + import {until} from '#/lib/async/until' 5 + import {logger} from '#/logger' 6 + import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 7 + import {useAgent, useSession} from '#/state/session' 8 + import type * as bsky from '#/types/bsky' 9 + 10 + export function useVerificationCreateMutation() { 11 + const agent = useAgent() 12 + const {currentAccount} = useSession() 13 + const updateProfileVerificationCache = useUpdateProfileVerificationCache() 14 + 15 + return useMutation({ 16 + async mutationFn({profile}: {profile: bsky.profile.AnyProfileView}) { 17 + if (!currentAccount) { 18 + throw new Error('User not logged in') 19 + } 20 + 21 + const {uri} = await agent.app.bsky.graph.verification.create( 22 + {repo: currentAccount.did}, 23 + { 24 + subject: profile.did, 25 + createdAt: new Date().toISOString(), 26 + handle: profile.handle, 27 + displayName: profile.displayName || '', 28 + }, 29 + ) 30 + 31 + await until( 32 + 5, 33 + 1e3, 34 + ({data: profile}: AppBskyActorGetProfile.Response) => { 35 + if ( 36 + profile.verification && 37 + profile.verification.verifications.find(v => v.uri === uri) 38 + ) { 39 + return true 40 + } 41 + return false 42 + }, 43 + () => { 44 + return agent.getProfile({actor: profile.did ?? ''}) 45 + }, 46 + ) 47 + }, 48 + async onSuccess(_, {profile}) { 49 + logger.metric('verification:create', {}) 50 + await updateProfileVerificationCache({profile}) 51 + }, 52 + }) 53 + }
+63
src/state/queries/verification/useVerificationsRemoveMutation.tsx
··· 1 + import { 2 + type AppBskyActorDefs, 3 + type AppBskyActorGetProfile, 4 + AtUri, 5 + } from '@atproto/api' 6 + import {useMutation} from '@tanstack/react-query' 7 + 8 + import {until} from '#/lib/async/until' 9 + import {logger} from '#/logger' 10 + import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 11 + import {useAgent, useSession} from '#/state/session' 12 + import type * as bsky from '#/types/bsky' 13 + 14 + export function useVerificationsRemoveMutation() { 15 + const agent = useAgent() 16 + const {currentAccount} = useSession() 17 + const updateProfileVerificationCache = useUpdateProfileVerificationCache() 18 + 19 + return useMutation({ 20 + async mutationFn({ 21 + profile, 22 + verifications, 23 + }: { 24 + profile: bsky.profile.AnyProfileView 25 + verifications: AppBskyActorDefs.VerificationView[] 26 + }) { 27 + if (!currentAccount) { 28 + throw new Error('User not logged in') 29 + } 30 + 31 + const uris = verifications.map(v => v.uri) 32 + 33 + await Promise.all( 34 + uris.map(uri => { 35 + return agent.app.bsky.graph.verification.delete({ 36 + repo: currentAccount.did, 37 + rkey: new AtUri(uri).rkey, 38 + }) 39 + }), 40 + ) 41 + 42 + await until( 43 + 5, 44 + 1e3, 45 + ({data: profile}: AppBskyActorGetProfile.Response) => { 46 + if ( 47 + !profile.verification?.verifications.some(v => uris.includes(v.uri)) 48 + ) { 49 + return true 50 + } 51 + return false 52 + }, 53 + () => { 54 + return agent.getProfile({actor: profile.did ?? ''}) 55 + }, 56 + ) 57 + }, 58 + async onSuccess(_, {profile}) { 59 + logger.metric('verification:revoke', {}) 60 + await updateProfileVerificationCache({profile}) 61 + }, 62 + }) 63 + }
+1 -1
src/types/bsky/profile.ts
··· 1 - import {AppBskyActorDefs, ChatBskyActorDefs} from '@atproto/api' 1 + import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api' 2 2 3 3 /** 4 4 * Matches any profile view exported by our SDK
+56 -54
src/view/com/composer/ComposerReplyTo.tsx
··· 1 - import React from 'react' 2 - import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native' 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {LayoutAnimation, Pressable, View} from 'react-native' 3 3 import {Image} from 'expo-image' 4 4 import { 5 5 AppBskyEmbedImages, ··· 12 12 13 13 import {sanitizeDisplayName} from '#/lib/strings/display-names' 14 14 import {sanitizeHandle} from '#/lib/strings/handles' 15 - import {ComposerOptsPostRef} from '#/state/shell/composer' 15 + import {type ComposerOptsPostRef} from '#/state/shell/composer' 16 16 import {MaybeQuoteEmbed} from '#/view/com/util/post-embeds/QuoteEmbed' 17 - import {Text} from '#/view/com/util/text/Text' 18 17 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 19 18 import {atoms as a, useTheme} from '#/alf' 19 + import {Text} from '#/components/Typography' 20 + import {useSimpleVerificationState} from '#/components/verification' 21 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 20 22 21 23 export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { 22 24 const t = useTheme() 23 25 const {_} = useLingui() 24 26 const {embed} = replyTo 25 27 26 - const [showFull, setShowFull] = React.useState(false) 28 + const [showFull, setShowFull] = useState(false) 27 29 28 - const onPress = React.useCallback(() => { 30 + const onPress = useCallback(() => { 29 31 setShowFull(prev => !prev) 30 32 LayoutAnimation.configureNext({ 31 33 duration: 350, ··· 33 35 }) 34 36 }, []) 35 37 36 - const quoteEmbed = React.useMemo(() => { 38 + const quoteEmbed = useMemo(() => { 37 39 if ( 38 40 AppBskyEmbedRecord.isView(embed) && 39 41 AppBskyEmbedRecord.isViewRecord(embed.record) && ··· 50 52 return null 51 53 }, [embed]) 52 54 53 - const images = React.useMemo(() => { 55 + const images = useMemo(() => { 54 56 if (AppBskyEmbedImages.isView(embed)) { 55 57 return embed.images 56 58 } else if ( ··· 61 63 } 62 64 }, [embed]) 63 65 66 + const verification = useSimpleVerificationState({profile: replyTo.author}) 67 + 64 68 return ( 65 69 <Pressable 66 - style={[t.atoms.border_contrast_medium, styles.replyToLayout]} 70 + style={[ 71 + a.flex_row, 72 + a.align_start, 73 + a.pt_xs, 74 + a.pb_lg, 75 + a.mb_md, 76 + a.mx_lg, 77 + a.border_b, 78 + t.atoms.border_contrast_medium, 79 + ]} 67 80 onPress={onPress} 68 81 accessibilityRole="button" 69 82 accessibilityLabel={_( 70 83 msg`Expand or collapse the full post you are replying to`, 71 84 )} 72 - accessibilityHint={_( 73 - msg`Expands or collapses the full post you are replying to`, 74 - )}> 85 + accessibilityHint=""> 75 86 <PreviewableUserAvatar 76 87 size={50} 77 88 profile={replyTo.author} ··· 79 90 type={replyTo.author.associated?.labeler ? 'labeler' : 'user'} 80 91 disableNavigation={true} 81 92 /> 82 - <View style={styles.replyToPost}> 83 - <Text type="xl-medium" style={t.atoms.text} numberOfLines={1} emoji> 84 - {sanitizeDisplayName( 85 - replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), 93 + <View style={[a.flex_1, a.pl_md, a.pr_sm, a.gap_2xs]}> 94 + <View style={[a.flex_row, a.align_center, a.pr_xs]}> 95 + <Text 96 + style={[a.font_bold, a.text_md, a.flex_shrink]} 97 + numberOfLines={1} 98 + emoji> 99 + {sanitizeDisplayName( 100 + replyTo.author.displayName || 101 + sanitizeHandle(replyTo.author.handle), 102 + )} 103 + </Text> 104 + {verification.showBadge && ( 105 + <View style={[a.pl_xs]}> 106 + <VerificationCheck 107 + width={14} 108 + verifier={verification.role === 'verifier'} 109 + /> 110 + </View> 86 111 )} 87 - </Text> 88 - <View style={styles.replyToBody}> 89 - <View style={styles.replyToText}> 112 + </View> 113 + <View style={[a.flex_row, a.gap_md]}> 114 + <View style={[a.flex_1, a.flex_grow]}> 90 115 <Text 91 - type="post-text" 92 - style={t.atoms.text} 116 + style={[a.text_md]} 93 117 numberOfLines={!showFull ? 6 : undefined} 94 118 emoji> 95 119 {replyTo.text} ··· 112 136 showFull: boolean 113 137 }) { 114 138 return ( 115 - <View style={[styles.imagesContainer, a.mx_xs]}> 139 + <View 140 + style={[ 141 + a.rounded_xs, 142 + a.overflow_hidden, 143 + a.mt_2xs, 144 + a.mx_xs, 145 + { 146 + height: 64, 147 + width: 64, 148 + }, 149 + ]}> 116 150 {(images.length === 1 && ( 117 151 <Image 118 152 source={{uri: images[0].thumb}} ··· 196 230 </View> 197 231 ) 198 232 } 199 - 200 - const styles = StyleSheet.create({ 201 - replyToLayout: { 202 - flexDirection: 'row', 203 - alignItems: 'flex-start', 204 - borderBottomWidth: StyleSheet.hairlineWidth, 205 - paddingTop: 4, 206 - paddingBottom: 16, 207 - marginBottom: 12, 208 - marginHorizontal: 16, 209 - }, 210 - replyToPost: { 211 - flex: 1, 212 - paddingLeft: 13, 213 - paddingRight: 8, 214 - }, 215 - replyToBody: { 216 - flexDirection: 'row', 217 - gap: 10, 218 - }, 219 - replyToText: { 220 - flex: 1, 221 - flexGrow: 1, 222 - }, 223 - imagesContainer: { 224 - borderRadius: 6, 225 - overflow: 'hidden', 226 - marginTop: 2, 227 - height: 64, 228 - width: 64, 229 - }, 230 - })
+29 -3
src/view/com/modals/EditProfile.tsx
··· 8 8 TouchableOpacity, 9 9 View, 10 10 } from 'react-native' 11 - import {Image as RNImage} from 'react-native-image-crop-picker' 11 + import {type Image as RNImage} from 'react-native-image-crop-picker' 12 12 import Animated, {FadeOut} from 'react-native-reanimated' 13 13 import {LinearGradient} from 'expo-linear-gradient' 14 - import {AppBskyActorDefs} from '@atproto/api' 14 + import {type AppBskyActorDefs} from '@atproto/api' 15 15 import {msg, Trans} from '@lingui/macro' 16 16 import {useLingui} from '@lingui/react' 17 17 18 - import {MAX_DESCRIPTION, MAX_DISPLAY_NAME} from '#/lib/constants' 18 + import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants' 19 19 import {usePalette} from '#/lib/hooks/usePalette' 20 20 import {compressIfNeeded} from '#/lib/media/manip' 21 21 import {cleanError} from '#/lib/strings/errors' ··· 30 30 import * as Toast from '#/view/com/util/Toast' 31 31 import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 32 32 import {UserBanner} from '#/view/com/util/UserBanner' 33 + import {Admonition} from '#/components/Admonition' 34 + import {InlineLinkText} from '#/components/Link' 35 + import {useSimpleVerificationState} from '#/components/verification' 33 36 import {ErrorMessage} from '../util/error/ErrorMessage' 34 37 35 38 const AnimatedTouchableOpacity = ··· 139 142 setImageError, 140 143 _, 141 144 ]) 145 + const verification = useSimpleVerificationState({ 146 + profile, 147 + }) 148 + const [touchedDisplayName, setTouchedDisplayName] = useState(false) 142 149 143 150 return ( 144 151 <KeyboardAvoidingView style={s.flex1} behavior="height"> ··· 186 193 accessible={true} 187 194 accessibilityLabel={_(msg`Display name`)} 188 195 accessibilityHint={_(msg`Edit your display name`)} 196 + onFocus={() => setTouchedDisplayName(true)} 189 197 /> 198 + 199 + {verification.isVerified && 200 + verification.role === 'default' && 201 + touchedDisplayName && ( 202 + <View style={{paddingTop: 8}}> 203 + <Admonition type="error"> 204 + <Trans> 205 + You are verified. You will lose your verification status 206 + if you change your display name.{' '} 207 + <InlineLinkText 208 + label={_(msg`Learn more`)} 209 + to={urls.website.blog.initialVerificationAnnouncement}> 210 + <Trans>Learn more.</Trans> 211 + </InlineLinkText> 212 + </Trans> 213 + </Admonition> 214 + </View> 215 + )} 190 216 </View> 191 217 <View style={s.pb10}> 192 218 <Text style={[styles.label, pal.text]}>
+150 -54
src/view/com/notifications/NotificationFeedItem.tsx
··· 49 49 import {formatCount} from '#/view/com/util/numeric/format' 50 50 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 51 51 import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' 52 - import {atoms as a, useTheme} from '#/alf' 52 + import {atoms as a, platform, useTheme} from '#/alf' 53 53 import {Button, ButtonText} from '#/components/Button' 54 54 import { 55 55 ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ··· 59 59 import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 60 60 import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' 61 61 import {StarterPack} from '#/components/icons/StarterPack' 62 + import {VerifiedCheck} from '#/components/icons/VerifiedCheck' 62 63 import {InlineLinkText, Link} from '#/components/Link' 63 64 import * as MediaPreview from '#/components/MediaPreview' 64 65 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 65 66 import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' 66 67 import {SubtleWebHover} from '#/components/SubtleWebHover' 67 68 import {Text} from '#/components/Typography' 69 + import {useSimpleVerificationState} from '#/components/verification' 70 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 68 71 import * as bsky from '#/types/bsky' 69 72 70 73 const MAX_AUTHORS = 5 ··· 145 148 146 149 const niceTimestamp = niceDate(i18n, item.notification.indexedAt) 147 150 const firstAuthor = authors[0] 151 + const firstAuthorVerification = useSimpleVerificationState({ 152 + profile: firstAuthor.profile, 153 + }) 148 154 const firstAuthorName = sanitizeDisplayName( 149 155 firstAuthor.profile.displayName || firstAuthor.profile.handle, 150 156 ) ··· 186 192 emoji 187 193 label={_(msg`Go to ${firstAuthorName}'s profile`)}> 188 194 {forceLTR(firstAuthorName)} 195 + {firstAuthorVerification.showBadge && ( 196 + <View 197 + style={[ 198 + a.relative, 199 + { 200 + paddingTop: platform({android: 2}), 201 + marginBottom: platform({ios: -7}), 202 + top: platform({web: 1}), 203 + paddingLeft: 3, 204 + paddingRight: 2, 205 + }, 206 + ]}> 207 + <VerificationCheck 208 + width={14} 209 + verifier={firstAuthorVerification.role === 'verifier'} 210 + /> 211 + </View> 212 + )} 189 213 </InlineLinkText> 190 214 ) 191 215 const additionalAuthorsCount = authors.length - 1 ··· 366 390 <StarterPack width={30} gradient="sky" /> 367 391 </View> 368 392 ) 393 + // @ts-ignore TODO 394 + } else if (item.type === 'verified') { 395 + a11yLabel = hasMultipleAuthors 396 + ? _( 397 + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 398 + one: `${formattedAuthorsCount} other`, 399 + other: `${formattedAuthorsCount} others`, 400 + })} verified you`, 401 + ) 402 + : _(msg`${firstAuthorName} verified you`) 403 + notificationContent = hasMultipleAuthors ? ( 404 + <Trans> 405 + {firstAuthorLink} and{' '} 406 + <Text style={[pal.text, s.bold]}> 407 + <Plural 408 + value={additionalAuthorsCount} 409 + one={`${formattedAuthorsCount} other`} 410 + other={`${formattedAuthorsCount} others`} 411 + /> 412 + </Text>{' '} 413 + verified you 414 + </Trans> 415 + ) : ( 416 + <Trans>{firstAuthorLink} verified you</Trans> 417 + ) 418 + icon = <VerifiedCheck size="xl" /> 419 + // @ts-ignore TODO 420 + } else if (item.type === 'unverified') { 421 + a11yLabel = hasMultipleAuthors 422 + ? _( 423 + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 424 + one: `${formattedAuthorsCount} other`, 425 + other: `${formattedAuthorsCount} others`, 426 + })} removed their verifications from your account`, 427 + ) 428 + : _(msg`${firstAuthorName} removed their verification from your account`) 429 + notificationContent = hasMultipleAuthors ? ( 430 + <Trans> 431 + {firstAuthorLink} and{' '} 432 + <Text style={[pal.text, s.bold]}> 433 + <Plural 434 + value={additionalAuthorsCount} 435 + one={`${formattedAuthorsCount} other`} 436 + other={`${formattedAuthorsCount} others`} 437 + /> 438 + </Text>{' '} 439 + removed their verifications from your account 440 + </Trans> 441 + ) : ( 442 + <Trans> 443 + {firstAuthorLink} removed their verification from your account 444 + </Trans> 445 + ) 446 + icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} /> 369 447 } else { 370 448 return null 371 449 } ··· 447 525 style={[ 448 526 a.flex_row, 449 527 a.flex_wrap, 450 - a.pb_2xs, 451 528 {paddingTop: 6}, 452 529 a.self_start, 453 530 a.text_md, ··· 475 552 </Text> 476 553 </ExpandListPressable> 477 554 {item.type === 'post-like' || item.type === 'repost' ? ( 478 - <AdditionalPostText post={item.subject} /> 555 + <View style={[a.pt_2xs]}> 556 + <AdditionalPostText post={item.subject} /> 557 + </View> 479 558 ) : null} 480 559 {item.type === 'feedgen-like' && item.subjectUri ? ( 481 560 <FeedSourceCard ··· 672 751 visible: boolean 673 752 authors: Author[] 674 753 }) { 675 - const {_} = useLingui() 676 - const t = useTheme() 677 754 const heightInterp = useAnimatedValue(visible ? 1 : 0) 678 755 const targetHeight = 679 756 authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ ··· 692 769 <Animated.View style={[a.overflow_hidden, heightStyle]}> 693 770 {visible && 694 771 authors.map(author => ( 695 - <Link 696 - key={author.profile.did} 697 - label={author.profile.displayName || author.profile.handle} 698 - accessibilityHint={_(msg`Opens this profile`)} 699 - to={makeProfileLink({ 700 - did: author.profile.did, 701 - handle: author.profile.handle, 702 - })} 703 - style={styles.expandedAuthor}> 704 - <View style={[a.mr_sm]}> 705 - <ProfileHoverCard did={author.profile.did}> 706 - <UserAvatar 707 - size={35} 708 - avatar={author.profile.avatar} 709 - moderation={author.moderation.ui('avatar')} 710 - type={author.profile.associated?.labeler ? 'labeler' : 'user'} 711 - /> 712 - </ProfileHoverCard> 713 - </View> 714 - <View style={[a.flex_1]}> 715 - <View style={[a.flex_row, a.align_end]}> 716 - <Text 717 - numberOfLines={1} 718 - emoji 719 - style={[ 720 - a.text_md, 721 - a.font_bold, 722 - a.leading_tight, 723 - {maxWidth: '70%'}, 724 - ]}> 725 - {sanitizeDisplayName( 726 - author.profile.displayName || author.profile.handle, 727 - )} 728 - </Text> 729 - <Text 730 - numberOfLines={1} 731 - style={[ 732 - a.pl_xs, 733 - a.text_md, 734 - a.leading_tight, 735 - a.flex_shrink, 736 - t.atoms.text_contrast_medium, 737 - ]}> 738 - {sanitizeHandle(author.profile.handle, '@')} 739 - </Text> 740 - </View> 741 - </View> 742 - </Link> 772 + <ExpandedAuthorCard key={author.profile.did} author={author} /> 743 773 ))} 744 774 </Animated.View> 745 775 ) 746 776 } 747 777 778 + function ExpandedAuthorCard({author}: {author: Author}) { 779 + const t = useTheme() 780 + const {_} = useLingui() 781 + const verification = useSimpleVerificationState({ 782 + profile: author.profile, 783 + }) 784 + return ( 785 + <Link 786 + key={author.profile.did} 787 + label={author.profile.displayName || author.profile.handle} 788 + accessibilityHint={_(msg`Opens this profile`)} 789 + to={makeProfileLink({ 790 + did: author.profile.did, 791 + handle: author.profile.handle, 792 + })} 793 + style={styles.expandedAuthor}> 794 + <View style={[a.mr_sm]}> 795 + <ProfileHoverCard did={author.profile.did}> 796 + <UserAvatar 797 + size={35} 798 + avatar={author.profile.avatar} 799 + moderation={author.moderation.ui('avatar')} 800 + type={author.profile.associated?.labeler ? 'labeler' : 'user'} 801 + /> 802 + </ProfileHoverCard> 803 + </View> 804 + <View style={[a.flex_1]}> 805 + <View style={[a.flex_row, a.align_end]}> 806 + <Text 807 + numberOfLines={1} 808 + emoji 809 + style={[ 810 + a.text_md, 811 + a.font_bold, 812 + a.leading_tight, 813 + {maxWidth: '70%'}, 814 + ]}> 815 + {sanitizeDisplayName( 816 + author.profile.displayName || author.profile.handle, 817 + )} 818 + </Text> 819 + {verification.showBadge && ( 820 + <View style={[a.pl_xs, a.self_center]}> 821 + <VerificationCheck 822 + width={14} 823 + verifier={verification.role === 'verifier'} 824 + /> 825 + </View> 826 + )} 827 + <Text 828 + numberOfLines={1} 829 + style={[ 830 + a.pl_xs, 831 + a.text_md, 832 + a.leading_tight, 833 + a.flex_shrink, 834 + t.atoms.text_contrast_medium, 835 + ]}> 836 + {sanitizeHandle(author.profile.handle, '@')} 837 + </Text> 838 + </View> 839 + </View> 840 + </Link> 841 + ) 842 + } 843 + 748 844 function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { 749 845 const t = useTheme() 750 846 if ( ··· 761 857 {text?.length > 0 && ( 762 858 <Text 763 859 emoji 764 - style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 860 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 765 861 {text} 766 862 </Text> 767 863 )}
+32 -12
src/view/com/post-thread/PostThreadItem.tsx
··· 32 32 type Shadow, 33 33 usePostShadow, 34 34 } from '#/state/cache/post-shadow' 35 + import {useProfileShadow} from '#/state/cache/profile-shadow' 35 36 import {useLanguagePrefs} from '#/state/preferences' 36 37 import {type ThreadPost} from '#/state/queries/post-thread' 37 38 import {useSession} from '#/state/session' ··· 62 63 import {RichText} from '#/components/RichText' 63 64 import {SubtleWebHover} from '#/components/SubtleWebHover' 64 65 import {Text} from '#/components/Typography' 66 + import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 65 67 import {WhoCanReply} from '#/components/WhoCanReply' 66 68 import * as bsky from '#/types/bsky' 67 69 ··· 207 209 () => countLines(richText?.text) >= MAX_POST_LINES, 208 210 ) 209 211 const {currentAccount} = useSession() 212 + const shadowedPostAuthor = useProfileShadow(post.author) 210 213 const rootUri = record.reply?.root?.uri || post.uri 211 214 const postHref = React.useMemo(() => { 212 215 const urip = new AtUri(post.uri) ··· 329 332 type={post.author.associated?.labeler ? 'labeler' : 'user'} 330 333 /> 331 334 <View style={[a.flex_1]}> 332 - <Link style={s.flex1} href={authorHref} title={authorTitle}> 333 - <Text 334 - emoji 335 - style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]} 336 - numberOfLines={1}> 337 - {sanitizeDisplayName( 338 - post.author.displayName || 339 - sanitizeHandle(post.author.handle), 340 - moderation.ui('displayName'), 341 - )} 342 - </Text> 343 - </Link> 335 + <View style={[a.flex_row, a.align_center]}> 336 + <Link 337 + style={[a.flex_shrink]} 338 + href={authorHref} 339 + title={authorTitle}> 340 + <Text 341 + emoji 342 + style={[ 343 + a.text_lg, 344 + a.font_bold, 345 + a.leading_snug, 346 + a.self_start, 347 + ]} 348 + numberOfLines={1}> 349 + {sanitizeDisplayName( 350 + post.author.displayName || 351 + sanitizeHandle(post.author.handle), 352 + moderation.ui('displayName'), 353 + )} 354 + </Text> 355 + </Link> 356 + 357 + <View style={[{paddingLeft: 3, top: -1}]}> 358 + <VerificationCheckButton 359 + profile={shadowedPostAuthor} 360 + size="md" 361 + /> 362 + </View> 363 + </View> 344 364 <Link style={s.flex1} href={authorHref} title={authorTitle}> 345 365 <Text 346 366 emoji
+49 -3
src/view/com/profile/ProfileMenu.tsx
··· 1 1 import React, {memo} from 'react' 2 - import {AppBskyActorDefs} from '@atproto/api' 2 + import {type AppBskyActorDefs} from '@atproto/api' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 import {useNavigation} from '@react-navigation/native' ··· 7 7 8 8 import {HITSLOP_20} from '#/lib/constants' 9 9 import {makeProfileLink} from '#/lib/routes/links' 10 - import {NavigationProp} from '#/lib/routes/types' 10 + import {type NavigationProp} from '#/lib/routes/types' 11 11 import {shareText, shareUrl} from '#/lib/sharing' 12 12 import {toShareUrl} from '#/lib/strings/url-helpers' 13 13 import {logger} from '#/logger' 14 - import {Shadow} from '#/state/cache/types' 14 + import {type Shadow} from '#/state/cache/types' 15 15 import {useModalControls} from '#/state/modals' 16 16 import {useDevModeEnabled} from '#/state/preferences/dev-mode' 17 17 import { ··· 25 25 import * as Toast from '#/view/com/util/Toast' 26 26 import {Button, ButtonIcon} from '#/components/Button' 27 27 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 28 + import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' 29 + import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' 28 30 import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 29 31 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 30 32 import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' ··· 43 45 useReportDialogControl, 44 46 } from '#/components/moderation/ReportDialog' 45 47 import * as Prompt from '#/components/Prompt' 48 + import {useFullVerificationState} from '#/components/verification' 49 + import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' 50 + import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 46 51 47 52 let ProfileMenu = ({ 48 53 profile, ··· 61 66 const isFollowingBlockedAccount = isFollowing && isBlocked 62 67 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 63 68 const [devModeEnabled] = useDevModeEnabled() 69 + const verification = useFullVerificationState({profile}) 64 70 65 71 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 66 72 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) ··· 188 194 navigation.navigate('ProfileSearch', {name: profile.handle}) 189 195 }, [navigation, profile.handle]) 190 196 197 + const verificationCreatePromptControl = Prompt.usePromptControl() 198 + const verificationRemovePromptControl = Prompt.usePromptControl() 199 + const currentAccountVerifications = 200 + profile.verification?.verifications?.filter(v => { 201 + return v.issuer === currentAccount?.did 202 + }) ?? [] 203 + 191 204 return ( 192 205 <EventStopper onKeyDown={false}> 193 206 <Menu.Root> ··· 277 290 </Menu.ItemText> 278 291 <Menu.ItemIcon icon={List} /> 279 292 </Menu.Item> 293 + {verification.viewer.role === 'verifier' && 294 + !verification.profile.isViewer && 295 + (verification.viewer.hasIssuedVerification ? ( 296 + <Menu.Item 297 + testID="profileHeaderDropdownVerificationRemoveButton" 298 + label={_(msg`Remove verification`)} 299 + onPress={() => verificationRemovePromptControl.open()}> 300 + <Menu.ItemText> 301 + <Trans>Remove verification</Trans> 302 + </Menu.ItemText> 303 + <Menu.ItemIcon icon={CircleX} /> 304 + </Menu.Item> 305 + ) : ( 306 + <Menu.Item 307 + testID="profileHeaderDropdownVerificationCreateButton" 308 + label={_(msg`Verify account`)} 309 + onPress={() => verificationCreatePromptControl.open()}> 310 + <Menu.ItemText> 311 + <Trans>Verify account</Trans> 312 + </Menu.ItemText> 313 + <Menu.ItemIcon icon={CircleCheck} /> 314 + </Menu.Item> 315 + ))} 280 316 {!isSelf && ( 281 317 <> 282 318 {!profile.viewer?.blocking && ··· 409 445 )} 410 446 onConfirm={onPressShare} 411 447 confirmButtonCta={_(msg`Share anyway`)} 448 + /> 449 + 450 + <VerificationCreatePrompt 451 + control={verificationCreatePromptControl} 452 + profile={profile} 453 + /> 454 + <VerificationRemovePrompt 455 + control={verificationRemovePromptControl} 456 + profile={profile} 457 + verifications={currentAccountVerifications} 412 458 /> 413 459 </EventStopper> 414 460 )
+106 -69
src/view/com/util/PostMeta.tsx
··· 1 - import React, {memo, useCallback} from 'react' 2 - import {StyleProp, View, ViewStyle} from 'react-native' 3 - import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 1 + import {memo, useCallback} from 'react' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 + import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import {useQueryClient} from '@tanstack/react-query' 7 + import type React from 'react' 7 8 8 9 import {makeProfileLink} from '#/lib/routes/links' 9 10 import {forceLTR} from '#/lib/strings/bidi' ··· 12 13 import {sanitizeHandle} from '#/lib/strings/handles' 13 14 import {niceDate} from '#/lib/strings/time' 14 15 import {isAndroid} from '#/platform/detection' 16 + import {useProfileShadow} from '#/state/cache/profile-shadow' 15 17 import {precacheProfile} from '#/state/queries/profile' 16 - import {atoms as a, useTheme, web} from '#/alf' 18 + import {atoms as a, platform, useTheme, web} from '#/alf' 17 19 import {WebOnlyInlineLinkText} from '#/components/Link' 18 20 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 19 21 import {Text} from '#/components/Typography' 22 + import {useSimpleVerificationState} from '#/components/verification' 23 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 20 24 import {TimeElapsed} from './TimeElapsed' 21 25 import {PreviewableUserAvatar} from './UserAvatar' 22 26 ··· 35 39 const t = useTheme() 36 40 const {i18n, _} = useLingui() 37 41 38 - const displayName = opts.author.displayName || opts.author.handle 39 - const handle = opts.author.handle 40 - const profileLink = makeProfileLink(opts.author) 42 + const author = useProfileShadow(opts.author) 43 + const displayName = author.displayName || author.handle 44 + const handle = author.handle 45 + const profileLink = makeProfileLink(author) 41 46 const queryClient = useQueryClient() 42 47 const onOpenAuthor = opts.onOpenAuthor 43 48 const onBeforePressAuthor = useCallback(() => { 44 - precacheProfile(queryClient, opts.author) 49 + precacheProfile(queryClient, author) 45 50 onOpenAuthor?.() 46 - }, [queryClient, opts.author, onOpenAuthor]) 51 + }, [queryClient, author, onOpenAuthor]) 47 52 const onBeforePressPost = useCallback(() => { 48 - precacheProfile(queryClient, opts.author) 49 - }, [queryClient, opts.author]) 53 + precacheProfile(queryClient, author) 54 + }, [queryClient, author]) 50 55 51 56 const timestampLabel = niceDate(i18n, opts.timestamp) 57 + const verification = useSimpleVerificationState({profile: author}) 52 58 53 59 return ( 54 60 <View ··· 56 62 a.flex_1, 57 63 a.flex_row, 58 64 a.align_center, 59 - a.pb_2xs, 65 + a.pb_xs, 60 66 a.gap_xs, 61 - a.z_10, 67 + a.z_20, 62 68 opts.style, 63 69 ]}> 64 70 {opts.showAvatar && ( 65 71 <View style={[a.self_center, a.mr_2xs]}> 66 72 <PreviewableUserAvatar 67 73 size={opts.avatarSize || 16} 68 - profile={opts.author} 74 + profile={author} 69 75 moderation={opts.moderation?.ui('avatar')} 70 - type={opts.author.associated?.labeler ? 'labeler' : 'user'} 76 + type={author.associated?.labeler ? 'labeler' : 'user'} 71 77 /> 72 78 </View> 73 79 )} 74 - <ProfileHoverCard inline did={opts.author.did}> 75 - <Text numberOfLines={1} style={[isAndroid ? a.flex_1 : a.flex_shrink]}> 76 - <WebOnlyInlineLinkText 77 - to={profileLink} 78 - label={_(msg`View profile`)} 79 - disableMismatchWarning 80 - onPress={onBeforePressAuthor} 81 - style={[t.atoms.text]}> 82 - <Text emoji style={[a.text_md, a.font_bold, a.leading_snug]}> 80 + <View style={[a.flex_row, a.align_end, a.flex_shrink]}> 81 + <ProfileHoverCard inline did={author.did}> 82 + <View style={[a.flex_row, a.align_end, a.flex_shrink]}> 83 + <WebOnlyInlineLinkText 84 + emoji 85 + numberOfLines={1} 86 + to={profileLink} 87 + label={_(msg`View profile`)} 88 + disableMismatchWarning 89 + onPress={onBeforePressAuthor} 90 + style={[ 91 + a.text_md, 92 + a.font_bold, 93 + t.atoms.text, 94 + a.leading_tight, 95 + {maxWidth: '70%', flexShrink: 0}, 96 + ]}> 83 97 {forceLTR( 84 98 sanitizeDisplayName( 85 99 displayName, 86 100 opts.moderation?.ui('displayName'), 87 101 ), 88 102 )} 89 - </Text> 90 - </WebOnlyInlineLinkText> 91 - <WebOnlyInlineLinkText 92 - to={profileLink} 93 - label={_(msg`View profile`)} 94 - disableMismatchWarning 95 - disableUnderline 96 - onPress={onBeforePressAuthor} 97 - style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}> 98 - <Text 99 - emoji 100 - style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}> 103 + </WebOnlyInlineLinkText> 104 + {verification.showBadge && ( 105 + <View 106 + style={[ 107 + a.pl_2xs, 108 + a.self_center, 109 + { 110 + marginTop: platform({web: -1, ios: -1, android: -2}), 111 + }, 112 + ]}> 113 + <VerificationCheck 114 + width={14} 115 + verifier={verification.role === 'verifier'} 116 + /> 117 + </View> 118 + )} 119 + <WebOnlyInlineLinkText 120 + numberOfLines={1} 121 + to={profileLink} 122 + label={_(msg`View profile`)} 123 + disableMismatchWarning 124 + disableUnderline 125 + onPress={onBeforePressAuthor} 126 + style={[ 127 + a.text_md, 128 + t.atoms.text_contrast_medium, 129 + a.leading_tight, 130 + {flexShrink: 10}, 131 + ]}> 101 132 {NON_BREAKING_SPACE + sanitizeHandle(handle, '@')} 102 - </Text> 103 - </WebOnlyInlineLinkText> 104 - </Text> 105 - </ProfileHoverCard> 133 + </WebOnlyInlineLinkText> 134 + </View> 135 + </ProfileHoverCard> 106 136 107 - {!isAndroid && ( 108 - <Text 109 - style={[a.text_md, t.atoms.text_contrast_medium]} 110 - accessible={false}> 111 - &middot; 112 - </Text> 113 - )} 114 - 115 - <TimeElapsed timestamp={opts.timestamp}> 116 - {({timeElapsed}) => ( 117 - <WebOnlyInlineLinkText 118 - to={opts.postHref} 119 - label={timestampLabel} 120 - title={timestampLabel} 121 - disableMismatchWarning 122 - disableUnderline 123 - onPress={onBeforePressPost} 124 - style={[ 125 - a.text_md, 126 - t.atoms.text_contrast_medium, 127 - a.leading_snug, 128 - web({ 129 - whiteSpace: 'nowrap', 130 - }), 131 - ]}> 132 - {timeElapsed} 133 - </WebOnlyInlineLinkText> 134 - )} 135 - </TimeElapsed> 137 + <TimeElapsed timestamp={opts.timestamp}> 138 + {({timeElapsed}) => ( 139 + <WebOnlyInlineLinkText 140 + to={opts.postHref} 141 + label={timestampLabel} 142 + title={timestampLabel} 143 + disableMismatchWarning 144 + disableUnderline 145 + onPress={onBeforePressPost} 146 + style={[ 147 + a.pl_xs, 148 + a.text_md, 149 + a.leading_tight, 150 + isAndroid && a.flex_grow, 151 + a.text_right, 152 + t.atoms.text_contrast_medium, 153 + web({ 154 + whiteSpace: 'nowrap', 155 + }), 156 + ]}> 157 + {!isAndroid && ( 158 + <Text 159 + style={[ 160 + a.text_md, 161 + a.leading_tight, 162 + t.atoms.text_contrast_medium, 163 + ]} 164 + accessible={false}> 165 + &middot;{' '} 166 + </Text> 167 + )} 168 + {timeElapsed} 169 + </WebOnlyInlineLinkText> 170 + )} 171 + </TimeElapsed> 172 + </View> 136 173 </View> 137 174 ) 138 175 }
+23 -7
src/view/shell/Drawer.tsx
··· 51 51 } from '#/components/icons/UserCircle' 52 52 import {InlineLinkText} from '#/components/Link' 53 53 import {Text} from '#/components/Typography' 54 + import {useSimpleVerificationState} from '#/components/verification' 55 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 54 56 55 57 const iconWidth = 26 56 58 ··· 64 66 const {_, i18n} = useLingui() 65 67 const t = useTheme() 66 68 const {data: profile} = useProfileQuery({did: account.did}) 69 + const verification = useSimpleVerificationState({profile}) 67 70 68 71 return ( 69 72 <TouchableOpacity ··· 71 74 accessibilityLabel={_(msg`Profile`)} 72 75 accessibilityHint={_(msg`Navigates to your profile`)} 73 76 onPress={onPressProfile} 74 - style={[a.gap_sm]}> 77 + style={[a.gap_sm, a.pr_lg]}> 75 78 <UserAvatar 76 79 size={52} 77 80 avatar={profile?.avatar} ··· 80 83 type={profile?.associated?.labeler ? 'labeler' : 'user'} 81 84 /> 82 85 <View style={[a.gap_2xs]}> 83 - <Text 84 - emoji 85 - style={[a.font_heavy, a.text_xl, a.mt_2xs, a.leading_tight]} 86 - numberOfLines={1}> 87 - {profile?.displayName || account.handle} 88 - </Text> 86 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 87 + <Text 88 + emoji 89 + style={[a.font_heavy, a.text_xl, a.mt_2xs, a.leading_tight]} 90 + numberOfLines={1}> 91 + {profile?.displayName || account.handle} 92 + </Text> 93 + {verification.showBadge && ( 94 + <View 95 + style={{ 96 + top: 0, 97 + }}> 98 + <VerificationCheck 99 + width={16} 100 + verifier={verification.role === 'verifier'} 101 + /> 102 + </View> 103 + )} 104 + </View> 89 105 <Text 90 106 emoji 91 107 style={[t.atoms.text_contrast_medium, a.text_md, a.leading_tight]}
+4 -4
yarn.lock
··· 80 80 tlds "^1.234.0" 81 81 zod "^3.23.8" 82 82 83 - "@atproto/api@^0.14.21": 84 - version "0.14.21" 85 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.21.tgz#29c189b7dba316945cf7317b9ded49b1b60d3ad9" 86 - integrity sha512-hCIcjks/snscH3ZtZFoicQN2hRM5MpWQUvvzyIa265XQ2vSv5BP+gsQVIHWtYKt+gzwq1E7jY4us6c4N7fsLlQ== 83 + "@atproto/api@^0.15.3": 84 + version "0.15.3" 85 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.3.tgz#f69f32f5446bfa38ff41b12a98078a61a07f6b49" 86 + integrity sha512-HrNaKWHZoVv4pxrt5ITyqG/f1veEitm6Egrvs4ZaDS1FyYDLNVdgLDr4ccW76iFs8ja1xQuQtZNakHbgQUN92w== 87 87 dependencies: 88 88 "@atproto/common-web" "^0.4.1" 89 89 "@atproto/lexicon" "^0.4.10"