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:
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user