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>
This commit is contained in:
Eric Bailey
2025-04-18 21:15:32 -05:00
committed by GitHub
parent f1e44ee12e
commit 0ac15920a4
63 changed files with 2332 additions and 395 deletions
+56 -54
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native'
import {useCallback, useMemo, useState} from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {
AppBskyEmbedImages,
@@ -12,20 +12,22 @@ import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {ComposerOptsPostRef} from '#/state/shell/composer'
import {type ComposerOptsPostRef} from '#/state/shell/composer'
import {MaybeQuoteEmbed} from '#/view/com/util/post-embeds/QuoteEmbed'
import {Text} from '#/view/com/util/text/Text'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
const t = useTheme()
const {_} = useLingui()
const {embed} = replyTo
const [showFull, setShowFull] = React.useState(false)
const [showFull, setShowFull] = useState(false)
const onPress = React.useCallback(() => {
const onPress = useCallback(() => {
setShowFull(prev => !prev)
LayoutAnimation.configureNext({
duration: 350,
@@ -33,7 +35,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
})
}, [])
const quoteEmbed = React.useMemo(() => {
const quoteEmbed = useMemo(() => {
if (
AppBskyEmbedRecord.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record) &&
@@ -50,7 +52,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
return null
}, [embed])
const images = React.useMemo(() => {
const images = useMemo(() => {
if (AppBskyEmbedImages.isView(embed)) {
return embed.images
} else if (
@@ -61,17 +63,26 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
}
}, [embed])
const verification = useSimpleVerificationState({profile: replyTo.author})
return (
<Pressable
style={[t.atoms.border_contrast_medium, styles.replyToLayout]}
style={[
a.flex_row,
a.align_start,
a.pt_xs,
a.pb_lg,
a.mb_md,
a.mx_lg,
a.border_b,
t.atoms.border_contrast_medium,
]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Expand or collapse the full post you are replying to`,
)}
accessibilityHint={_(
msg`Expands or collapses the full post you are replying to`,
)}>
accessibilityHint="">
<PreviewableUserAvatar
size={50}
profile={replyTo.author}
@@ -79,17 +90,30 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
disableNavigation={true}
/>
<View style={styles.replyToPost}>
<Text type="xl-medium" style={t.atoms.text} numberOfLines={1} emoji>
{sanitizeDisplayName(
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
<View style={[a.flex_1, a.pl_md, a.pr_sm, a.gap_2xs]}>
<View style={[a.flex_row, a.align_center, a.pr_xs]}>
<Text
style={[a.font_bold, a.text_md, a.flex_shrink]}
numberOfLines={1}
emoji>
{sanitizeDisplayName(
replyTo.author.displayName ||
sanitizeHandle(replyTo.author.handle),
)}
</Text>
{verification.showBadge && (
<View style={[a.pl_xs]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
</Text>
<View style={styles.replyToBody}>
<View style={styles.replyToText}>
</View>
<View style={[a.flex_row, a.gap_md]}>
<View style={[a.flex_1, a.flex_grow]}>
<Text
type="post-text"
style={t.atoms.text}
style={[a.text_md]}
numberOfLines={!showFull ? 6 : undefined}
emoji>
{replyTo.text}
@@ -112,7 +136,17 @@ function ComposerReplyToImages({
showFull: boolean
}) {
return (
<View style={[styles.imagesContainer, a.mx_xs]}>
<View
style={[
a.rounded_xs,
a.overflow_hidden,
a.mt_2xs,
a.mx_xs,
{
height: 64,
width: 64,
},
]}>
{(images.length === 1 && (
<Image
source={{uri: images[0].thumb}}
@@ -196,35 +230,3 @@ function ComposerReplyToImages({
</View>
)
}
const styles = StyleSheet.create({
replyToLayout: {
flexDirection: 'row',
alignItems: 'flex-start',
borderBottomWidth: StyleSheet.hairlineWidth,
paddingTop: 4,
paddingBottom: 16,
marginBottom: 12,
marginHorizontal: 16,
},
replyToPost: {
flex: 1,
paddingLeft: 13,
paddingRight: 8,
},
replyToBody: {
flexDirection: 'row',
gap: 10,
},
replyToText: {
flex: 1,
flexGrow: 1,
},
imagesContainer: {
borderRadius: 6,
overflow: 'hidden',
marginTop: 2,
height: 64,
width: 64,
},
})
+29 -3
View File
@@ -8,14 +8,14 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {type Image as RNImage} from 'react-native-image-crop-picker'
import Animated, {FadeOut} from 'react-native-reanimated'
import {LinearGradient} from 'expo-linear-gradient'
import {AppBskyActorDefs} from '@atproto/api'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {MAX_DESCRIPTION, MAX_DISPLAY_NAME} from '#/lib/constants'
import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {compressIfNeeded} from '#/lib/media/manip'
import {cleanError} from '#/lib/strings/errors'
@@ -30,6 +30,9 @@ import {Text} from '#/view/com/util/text/Text'
import * as Toast from '#/view/com/util/Toast'
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
import {UserBanner} from '#/view/com/util/UserBanner'
import {Admonition} from '#/components/Admonition'
import {InlineLinkText} from '#/components/Link'
import {useSimpleVerificationState} from '#/components/verification'
import {ErrorMessage} from '../util/error/ErrorMessage'
const AnimatedTouchableOpacity =
@@ -139,6 +142,10 @@ export function Component({
setImageError,
_,
])
const verification = useSimpleVerificationState({
profile,
})
const [touchedDisplayName, setTouchedDisplayName] = useState(false)
return (
<KeyboardAvoidingView style={s.flex1} behavior="height">
@@ -186,7 +193,26 @@ export function Component({
accessible={true}
accessibilityLabel={_(msg`Display name`)}
accessibilityHint={_(msg`Edit your display name`)}
onFocus={() => setTouchedDisplayName(true)}
/>
{verification.isVerified &&
verification.role === 'default' &&
touchedDisplayName && (
<View style={{paddingTop: 8}}>
<Admonition type="error">
<Trans>
You are verified. You will lose your verification status
if you change your display name.{' '}
<InlineLinkText
label={_(msg`Learn more`)}
to={urls.website.blog.initialVerificationAnnouncement}>
<Trans>Learn more.</Trans>
</InlineLinkText>
</Trans>
</Admonition>
</View>
)}
</View>
<View style={s.pb10}>
<Text style={[styles.label, pal.text]}>
@@ -49,7 +49,7 @@ import {Post} from '#/view/com/post/Post'
import {formatCount} from '#/view/com/util/numeric/format'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, platform, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
@@ -59,12 +59,15 @@ import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/compon
import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import {StarterPack} from '#/components/icons/StarterPack'
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
import {InlineLinkText, Link} from '#/components/Link'
import * as MediaPreview from '#/components/MediaPreview'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import * as bsky from '#/types/bsky'
const MAX_AUTHORS = 5
@@ -145,6 +148,9 @@ let NotificationFeedItem = ({
const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
const firstAuthor = authors[0]
const firstAuthorVerification = useSimpleVerificationState({
profile: firstAuthor.profile,
})
const firstAuthorName = sanitizeDisplayName(
firstAuthor.profile.displayName || firstAuthor.profile.handle,
)
@@ -186,6 +192,24 @@ let NotificationFeedItem = ({
emoji
label={_(msg`Go to ${firstAuthorName}'s profile`)}>
{forceLTR(firstAuthorName)}
{firstAuthorVerification.showBadge && (
<View
style={[
a.relative,
{
paddingTop: platform({android: 2}),
marginBottom: platform({ios: -7}),
top: platform({web: 1}),
paddingLeft: 3,
paddingRight: 2,
},
]}>
<VerificationCheck
width={14}
verifier={firstAuthorVerification.role === 'verifier'}
/>
</View>
)}
</InlineLinkText>
)
const additionalAuthorsCount = authors.length - 1
@@ -366,6 +390,60 @@ let NotificationFeedItem = ({
<StarterPack width={30} gradient="sky" />
</View>
)
// @ts-ignore TODO
} else if (item.type === 'verified') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} verified you`,
)
: _(msg`${firstAuthorName} verified you`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[pal.text, s.bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
verified you
</Trans>
) : (
<Trans>{firstAuthorLink} verified you</Trans>
)
icon = <VerifiedCheck size="xl" />
// @ts-ignore TODO
} else if (item.type === 'unverified') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} removed their verifications from your account`,
)
: _(msg`${firstAuthorName} removed their verification from your account`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[pal.text, s.bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
removed their verifications from your account
</Trans>
) : (
<Trans>
{firstAuthorLink} removed their verification from your account
</Trans>
)
icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} />
} else {
return null
}
@@ -447,7 +525,6 @@ let NotificationFeedItem = ({
style={[
a.flex_row,
a.flex_wrap,
a.pb_2xs,
{paddingTop: 6},
a.self_start,
a.text_md,
@@ -475,7 +552,9 @@ let NotificationFeedItem = ({
</Text>
</ExpandListPressable>
{item.type === 'post-like' || item.type === 'repost' ? (
<AdditionalPostText post={item.subject} />
<View style={[a.pt_2xs]}>
<AdditionalPostText post={item.subject} />
</View>
) : null}
{item.type === 'feedgen-like' && item.subjectUri ? (
<FeedSourceCard
@@ -672,8 +751,6 @@ function ExpandedAuthorsList({
visible: boolean
authors: Author[]
}) {
const {_} = useLingui()
const t = useTheme()
const heightInterp = useAnimatedValue(visible ? 1 : 0)
const targetHeight =
authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/
@@ -692,59 +769,78 @@ function ExpandedAuthorsList({
<Animated.View style={[a.overflow_hidden, heightStyle]}>
{visible &&
authors.map(author => (
<Link
key={author.profile.did}
label={author.profile.displayName || author.profile.handle}
accessibilityHint={_(msg`Opens this profile`)}
to={makeProfileLink({
did: author.profile.did,
handle: author.profile.handle,
})}
style={styles.expandedAuthor}>
<View style={[a.mr_sm]}>
<ProfileHoverCard did={author.profile.did}>
<UserAvatar
size={35}
avatar={author.profile.avatar}
moderation={author.moderation.ui('avatar')}
type={author.profile.associated?.labeler ? 'labeler' : 'user'}
/>
</ProfileHoverCard>
</View>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_end]}>
<Text
numberOfLines={1}
emoji
style={[
a.text_md,
a.font_bold,
a.leading_tight,
{maxWidth: '70%'},
]}>
{sanitizeDisplayName(
author.profile.displayName || author.profile.handle,
)}
</Text>
<Text
numberOfLines={1}
style={[
a.pl_xs,
a.text_md,
a.leading_tight,
a.flex_shrink,
t.atoms.text_contrast_medium,
]}>
{sanitizeHandle(author.profile.handle, '@')}
</Text>
</View>
</View>
</Link>
<ExpandedAuthorCard key={author.profile.did} author={author} />
))}
</Animated.View>
)
}
function ExpandedAuthorCard({author}: {author: Author}) {
const t = useTheme()
const {_} = useLingui()
const verification = useSimpleVerificationState({
profile: author.profile,
})
return (
<Link
key={author.profile.did}
label={author.profile.displayName || author.profile.handle}
accessibilityHint={_(msg`Opens this profile`)}
to={makeProfileLink({
did: author.profile.did,
handle: author.profile.handle,
})}
style={styles.expandedAuthor}>
<View style={[a.mr_sm]}>
<ProfileHoverCard did={author.profile.did}>
<UserAvatar
size={35}
avatar={author.profile.avatar}
moderation={author.moderation.ui('avatar')}
type={author.profile.associated?.labeler ? 'labeler' : 'user'}
/>
</ProfileHoverCard>
</View>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_end]}>
<Text
numberOfLines={1}
emoji
style={[
a.text_md,
a.font_bold,
a.leading_tight,
{maxWidth: '70%'},
]}>
{sanitizeDisplayName(
author.profile.displayName || author.profile.handle,
)}
</Text>
{verification.showBadge && (
<View style={[a.pl_xs, a.self_center]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
<Text
numberOfLines={1}
style={[
a.pl_xs,
a.text_md,
a.leading_tight,
a.flex_shrink,
t.atoms.text_contrast_medium,
]}>
{sanitizeHandle(author.profile.handle, '@')}
</Text>
</View>
</View>
</Link>
)
}
function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const t = useTheme()
if (
@@ -761,7 +857,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
{text?.length > 0 && (
<Text
emoji
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{text}
</Text>
)}
+32 -12
View File
@@ -32,6 +32,7 @@ import {
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadPost} from '#/state/queries/post-thread'
import {useSession} from '#/state/session'
@@ -62,6 +63,7 @@ import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {WhoCanReply} from '#/components/WhoCanReply'
import * as bsky from '#/types/bsky'
@@ -207,6 +209,7 @@ let PostThreadItemLoaded = ({
() => countLines(richText?.text) >= MAX_POST_LINES,
)
const {currentAccount} = useSession()
const shadowedPostAuthor = useProfileShadow(post.author)
const rootUri = record.reply?.root?.uri || post.uri
const postHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
@@ -329,18 +332,35 @@ let PostThreadItemLoaded = ({
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_1]}>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text
emoji
style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</Text>
</Link>
<View style={[a.flex_row, a.align_center]}>
<Link
style={[a.flex_shrink]}
href={authorHref}
title={authorTitle}>
<Text
emoji
style={[
a.text_lg,
a.font_bold,
a.leading_snug,
a.self_start,
]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</Text>
</Link>
<View style={[{paddingLeft: 3, top: -1}]}>
<VerificationCheckButton
profile={shadowedPostAuthor}
size="md"
/>
</View>
</View>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text
emoji
+49 -3
View File
@@ -1,5 +1,5 @@
import React, {memo} from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -7,11 +7,11 @@ import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {Shadow} from '#/state/cache/types'
import {type Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
import {useDevModeEnabled} from '#/state/preferences/dev-mode'
import {
@@ -25,6 +25,8 @@ import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
import {Button, ButtonIcon} from '#/components/Button'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX'
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
@@ -43,6 +45,9 @@ import {
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {useFullVerificationState} from '#/components/verification'
import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt'
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
let ProfileMenu = ({
profile,
@@ -61,6 +66,7 @@ let ProfileMenu = ({
const isFollowingBlockedAccount = isFollowing && isBlocked
const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
const [devModeEnabled] = useDevModeEnabled()
const verification = useFullVerificationState({profile})
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
@@ -188,6 +194,13 @@ let ProfileMenu = ({
navigation.navigate('ProfileSearch', {name: profile.handle})
}, [navigation, profile.handle])
const verificationCreatePromptControl = Prompt.usePromptControl()
const verificationRemovePromptControl = Prompt.usePromptControl()
const currentAccountVerifications =
profile.verification?.verifications?.filter(v => {
return v.issuer === currentAccount?.did
}) ?? []
return (
<EventStopper onKeyDown={false}>
<Menu.Root>
@@ -277,6 +290,29 @@ let ProfileMenu = ({
</Menu.ItemText>
<Menu.ItemIcon icon={List} />
</Menu.Item>
{verification.viewer.role === 'verifier' &&
!verification.profile.isViewer &&
(verification.viewer.hasIssuedVerification ? (
<Menu.Item
testID="profileHeaderDropdownVerificationRemoveButton"
label={_(msg`Remove verification`)}
onPress={() => verificationRemovePromptControl.open()}>
<Menu.ItemText>
<Trans>Remove verification</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={CircleX} />
</Menu.Item>
) : (
<Menu.Item
testID="profileHeaderDropdownVerificationCreateButton"
label={_(msg`Verify account`)}
onPress={() => verificationCreatePromptControl.open()}>
<Menu.ItemText>
<Trans>Verify account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={CircleCheck} />
</Menu.Item>
))}
{!isSelf && (
<>
{!profile.viewer?.blocking &&
@@ -410,6 +446,16 @@ let ProfileMenu = ({
onConfirm={onPressShare}
confirmButtonCta={_(msg`Share anyway`)}
/>
<VerificationCreatePrompt
control={verificationCreatePromptControl}
profile={profile}
/>
<VerificationRemovePrompt
control={verificationRemovePromptControl}
profile={profile}
verifications={currentAccountVerifications}
/>
</EventStopper>
)
}
+106 -69
View File
@@ -1,9 +1,10 @@
import React, {memo, useCallback} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {memo, useCallback} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import type React from 'react'
import {makeProfileLink} from '#/lib/routes/links'
import {forceLTR} from '#/lib/strings/bidi'
@@ -12,11 +13,14 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time'
import {isAndroid} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {precacheProfile} from '#/state/queries/profile'
import {atoms as a, useTheme, web} from '#/alf'
import {atoms as a, platform, useTheme, web} from '#/alf'
import {WebOnlyInlineLinkText} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {TimeElapsed} from './TimeElapsed'
import {PreviewableUserAvatar} from './UserAvatar'
@@ -35,20 +39,22 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
const t = useTheme()
const {i18n, _} = useLingui()
const displayName = opts.author.displayName || opts.author.handle
const handle = opts.author.handle
const profileLink = makeProfileLink(opts.author)
const author = useProfileShadow(opts.author)
const displayName = author.displayName || author.handle
const handle = author.handle
const profileLink = makeProfileLink(author)
const queryClient = useQueryClient()
const onOpenAuthor = opts.onOpenAuthor
const onBeforePressAuthor = useCallback(() => {
precacheProfile(queryClient, opts.author)
precacheProfile(queryClient, author)
onOpenAuthor?.()
}, [queryClient, opts.author, onOpenAuthor])
}, [queryClient, author, onOpenAuthor])
const onBeforePressPost = useCallback(() => {
precacheProfile(queryClient, opts.author)
}, [queryClient, opts.author])
precacheProfile(queryClient, author)
}, [queryClient, author])
const timestampLabel = niceDate(i18n, opts.timestamp)
const verification = useSimpleVerificationState({profile: author})
return (
<View
@@ -56,83 +62,114 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
a.flex_1,
a.flex_row,
a.align_center,
a.pb_2xs,
a.pb_xs,
a.gap_xs,
a.z_10,
a.z_20,
opts.style,
]}>
{opts.showAvatar && (
<View style={[a.self_center, a.mr_2xs]}>
<PreviewableUserAvatar
size={opts.avatarSize || 16}
profile={opts.author}
profile={author}
moderation={opts.moderation?.ui('avatar')}
type={opts.author.associated?.labeler ? 'labeler' : 'user'}
type={author.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
)}
<ProfileHoverCard inline did={opts.author.did}>
<Text numberOfLines={1} style={[isAndroid ? a.flex_1 : a.flex_shrink]}>
<WebOnlyInlineLinkText
to={profileLink}
label={_(msg`View profile`)}
disableMismatchWarning
onPress={onBeforePressAuthor}
style={[t.atoms.text]}>
<Text emoji style={[a.text_md, a.font_bold, a.leading_snug]}>
<View style={[a.flex_row, a.align_end, a.flex_shrink]}>
<ProfileHoverCard inline did={author.did}>
<View style={[a.flex_row, a.align_end, a.flex_shrink]}>
<WebOnlyInlineLinkText
emoji
numberOfLines={1}
to={profileLink}
label={_(msg`View profile`)}
disableMismatchWarning
onPress={onBeforePressAuthor}
style={[
a.text_md,
a.font_bold,
t.atoms.text,
a.leading_tight,
{maxWidth: '70%', flexShrink: 0},
]}>
{forceLTR(
sanitizeDisplayName(
displayName,
opts.moderation?.ui('displayName'),
),
)}
</Text>
</WebOnlyInlineLinkText>
<WebOnlyInlineLinkText
to={profileLink}
label={_(msg`View profile`)}
disableMismatchWarning
disableUnderline
onPress={onBeforePressAuthor}
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
<Text
emoji
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
</WebOnlyInlineLinkText>
{verification.showBadge && (
<View
style={[
a.pl_2xs,
a.self_center,
{
marginTop: platform({web: -1, ios: -1, android: -2}),
},
]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
<WebOnlyInlineLinkText
numberOfLines={1}
to={profileLink}
label={_(msg`View profile`)}
disableMismatchWarning
disableUnderline
onPress={onBeforePressAuthor}
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_tight,
{flexShrink: 10},
]}>
{NON_BREAKING_SPACE + sanitizeHandle(handle, '@')}
</Text>
</WebOnlyInlineLinkText>
</Text>
</ProfileHoverCard>
</WebOnlyInlineLinkText>
</View>
</ProfileHoverCard>
{!isAndroid && (
<Text
style={[a.text_md, t.atoms.text_contrast_medium]}
accessible={false}>
&middot;
</Text>
)}
<TimeElapsed timestamp={opts.timestamp}>
{({timeElapsed}) => (
<WebOnlyInlineLinkText
to={opts.postHref}
label={timestampLabel}
title={timestampLabel}
disableMismatchWarning
disableUnderline
onPress={onBeforePressPost}
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
web({
whiteSpace: 'nowrap',
}),
]}>
{timeElapsed}
</WebOnlyInlineLinkText>
)}
</TimeElapsed>
<TimeElapsed timestamp={opts.timestamp}>
{({timeElapsed}) => (
<WebOnlyInlineLinkText
to={opts.postHref}
label={timestampLabel}
title={timestampLabel}
disableMismatchWarning
disableUnderline
onPress={onBeforePressPost}
style={[
a.pl_xs,
a.text_md,
a.leading_tight,
isAndroid && a.flex_grow,
a.text_right,
t.atoms.text_contrast_medium,
web({
whiteSpace: 'nowrap',
}),
]}>
{!isAndroid && (
<Text
style={[
a.text_md,
a.leading_tight,
t.atoms.text_contrast_medium,
]}
accessible={false}>
&middot;{' '}
</Text>
)}
{timeElapsed}
</WebOnlyInlineLinkText>
)}
</TimeElapsed>
</View>
</View>
)
}