about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/composer/ComposerReplyTo.tsx110
-rw-r--r--src/view/com/modals/EditProfile.tsx32
-rw-r--r--src/view/com/notifications/NotificationFeedItem.tsx204
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx44
-rw-r--r--src/view/com/profile/ProfileMenu.tsx52
-rw-r--r--src/view/com/util/PostMeta.tsx175
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}>
-          &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>
   )
 }