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