about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-05-31 19:10:00 +0300
committerGitHub <noreply@github.com>2024-05-31 11:10:00 -0500
commitcd3b502b343e5e79d9a6df77d08935829b655f55 (patch)
tree5f16d11441049dc8e2b0ed1ec029dfc6fc6832af
parent22e1eb18c81b6f41927bc86d4726223c2634e19e (diff)
downloadvoidsky-cd3b502b343e5e79d9a6df77d08935829b655f55.tar.zst
[🐴] Option to share via chat in post dropdown (#4231)
* add send via chat button to post dropdown

(cherry picked from commit d8458c0bc344f993266f7bc7e325d47e40619648)

* let usePostQuery take uris with DIDs

(cherry picked from commit 16b577ce749fd07e1d5f8461e8ca71c5b874a936)

* add embed preview in composer

(cherry picked from commit 795ceb98d55b6a3ab5b83187a582f9656d71db69)

* rm log

(cherry picked from commit 374d6b8869459f08d8442a3a47d67149e8d9ddd4)

* remove params properly, or at least as close to

(cherry picked from commit c20e0062c2ca4d9c2b28324eee5e713a1a3ab251)

* show images in preview

(cherry picked from commit 5bb617a3ce00f67bfc79784b2f81ef8dcb5bfc25)

* Register embed immediately

(cherry picked from commit ee120d5438a2c91c8980288665576d6a29b4c7e7)

* Add hover to match embeds

(cherry picked from commit 5297a5b06e499f46a9f6da510124610005db2448)

* Update post dropdown copy

(cherry picked from commit bc7e9f6a4303926a53c5c889f1f1b136faf20491)

* Embed preview style tweaks

(cherry picked from commit 9e3ccb0f25ac2f3ce6af538bb29112a3e96e01b1)

* use hydrated posts from API and just use postembed component

(cherry picked from commit cc0b84db87ca812d76cc69f46170ae84cfdde4ef)

* fix type error

(cherry picked from commit 9c49b940e1248e8a7c3b64190c5cb20750043619)

* undo needless export

(cherry picked from commit 1186701c997c50c0b29a809637cb9bc061b8c0a0)

* fix overflow

(cherry picked from commit 8868d5075062d0199c8ef6946fabde27e46ea378)

---------

Co-authored-by: Eric Bailey <git@esb.lol>
-rw-r--r--package.json2
-rw-r--r--src/components/dms/MessageItem.tsx2
-rw-r--r--src/components/dms/MessageItemEmbed.tsx99
-rw-r--r--src/components/dms/dialogs/NewChatDialog.tsx67
-rw-r--r--src/components/dms/dialogs/SearchablePeopleList.tsx (renamed from src/components/dms/NewChatDialog/index.tsx)454
-rw-r--r--src/components/dms/dialogs/ShareViaChatDialog.tsx52
-rw-r--r--src/components/dms/dialogs/TextInput.tsx (renamed from src/components/dms/NewChatDialog/TextInput.tsx)0
-rw-r--r--src/components/dms/dialogs/TextInput.web.tsx (renamed from src/components/dms/NewChatDialog/TextInput.web.tsx)0
-rw-r--r--src/lib/routes/types.ts2
-rw-r--r--src/lib/statsig/events.ts8
-rw-r--r--src/screens/Messages/Conversation/MessageInput.tsx23
-rw-r--r--src/screens/Messages/Conversation/MessageInput.web.tsx14
-rw-r--r--src/screens/Messages/Conversation/MessageInputEmbed.tsx231
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx89
-rw-r--r--src/screens/Messages/List/index.tsx2
-rw-r--r--src/state/messages/convo/agent.ts1
-rw-r--r--src/state/queries/post.ts13
-rw-r--r--src/view/com/notifications/FeedItem.tsx2
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx36
-rw-r--r--src/view/com/util/images/ImageHorzList.tsx11
-rw-r--r--yarn.lock12
21 files changed, 713 insertions, 407 deletions
diff --git a/package.json b/package.json
index 642bf935f..c107ea56e 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
     "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
   },
   "dependencies": {
-    "@atproto/api": "^0.12.13",
+    "@atproto/api": "^0.12.14",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index b498ddf1c..772fcb1b1 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -82,7 +82,7 @@ let MessageItem = ({
   return (
     <View style={[isFromSelf ? a.mr_md : a.ml_md]}>
       <ActionsWrapper isFromSelf={isFromSelf} message={message}>
-        {AppBskyEmbedRecord.isMain(message.embed) && (
+        {AppBskyEmbedRecord.isView(message.embed) && (
           <MessageItemEmbed embed={message.embed} />
         )}
         {rt.text.length > 0 && (
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index d64563b91..5d3656bac 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -1,108 +1,21 @@
-import React, {useMemo} from 'react'
+import React from 'react'
 import {View} from 'react-native'
-import {
-  AppBskyEmbedRecord,
-  AppBskyFeedPost,
-  AtUri,
-  RichText as RichTextAPI,
-} from '@atproto/api'
+import {AppBskyEmbedRecord} from '@atproto/api'
 
-import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
-import {makeProfileLink} from '#/lib/routes/links'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {usePostQuery} from '#/state/queries/post'
 import {PostEmbeds} from '#/view/com/util/post-embeds'
-import {PostMeta} from '#/view/com/util/PostMeta'
 import {atoms as a, useTheme} from '#/alf'
-import {Link} from '#/components/Link'
-import {ContentHider} from '#/components/moderation/ContentHider'
-import {PostAlerts} from '#/components/moderation/PostAlerts'
-import {RichText} from '#/components/RichText'
 
 let MessageItemEmbed = ({
   embed,
 }: {
-  embed: AppBskyEmbedRecord.Main
+  embed: AppBskyEmbedRecord.View
 }): React.ReactNode => {
   const t = useTheme()
-  const {data: post} = usePostQuery(embed.record.uri)
-
-  const moderationOpts = useModerationOpts()
-  const moderation = useMemo(
-    () =>
-      moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
-    [moderationOpts, post],
-  )
-
-  const {rt, record} = useMemo(() => {
-    if (
-      post &&
-      AppBskyFeedPost.isRecord(post.record) &&
-      AppBskyFeedPost.validateRecord(post.record).success
-    ) {
-      return {
-        rt: new RichTextAPI({
-          text: post.record.text,
-          facets: post.record.facets,
-        }),
-        record: post.record,
-      }
-    }
-
-    return {rt: undefined, record: undefined}
-  }, [post])
-
-  if (!post || !moderation || !rt || !record) {
-    return null
-  }
-
-  const itemUrip = new AtUri(post.uri)
-  const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
 
   return (
-    <Link to={itemHref}>
-      <View
-        style={[
-          a.w_full,
-          t.atoms.bg,
-          t.atoms.border_contrast_low,
-          a.rounded_md,
-          a.border,
-          a.p_md,
-          a.my_xs,
-        ]}>
-        <PostMeta
-          showAvatar
-          author={post.author}
-          moderation={moderation}
-          authorHasWarning={!!post.author.labels?.length}
-          timestamp={post.indexedAt}
-          postHref={itemHref}
-        />
-        <ContentHider modui={moderation.ui('contentView')}>
-          <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
-          {rt.text && (
-            <View style={a.mt_xs}>
-              <RichText
-                enableTags
-                testID="postText"
-                value={rt}
-                style={[a.text_sm, t.atoms.text_contrast_high]}
-                authorHandle={post.author.handle}
-              />
-            </View>
-          )}
-          {post.embed && (
-            <PostEmbeds
-              embed={post.embed}
-              moderation={moderation}
-              style={a.mt_xs}
-              quoteTextStyle={[a.text_sm, t.atoms.text_contrast_high]}
-            />
-          )}
-        </ContentHider>
-      </View>
-    </Link>
+    <View style={[a.my_xs, t.atoms.bg, a.rounded_md, {flexBasis: 0}]}>
+      <PostEmbeds embed={embed} />
+    </View>
   )
 }
 MessageItemEmbed = React.memo(MessageItemEmbed)
diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx
new file mode 100644
index 000000000..2b90fb02b
--- /dev/null
+++ b/src/components/dms/dialogs/NewChatDialog.tsx
@@ -0,0 +1,67 @@
+import React, {useCallback} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
+import {logEvent} from 'lib/statsig/statsig'
+import {FAB} from '#/view/com/util/fab/FAB'
+import * as Toast from '#/view/com/util/Toast'
+import {useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {SearchablePeopleList} from './SearchablePeopleList'
+
+export function NewChat({
+  control,
+  onNewChat,
+}: {
+  control: Dialog.DialogControlProps
+  onNewChat: (chatId: string) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const {mutate: createChat} = useGetConvoForMembers({
+    onSuccess: data => {
+      onNewChat(data.convo.id)
+
+      if (!data.convo.lastMessage) {
+        logEvent('chat:create', {logContext: 'NewChatDialog'})
+      }
+      logEvent('chat:open', {logContext: 'NewChatDialog'})
+    },
+    onError: error => {
+      Toast.show(error.message)
+    },
+  })
+
+  const onCreateChat = useCallback(
+    (did: string) => {
+      control.close(() => createChat([did]))
+    },
+    [control, createChat],
+  )
+
+  return (
+    <>
+      <FAB
+        testID="newChatFAB"
+        onPress={control.open}
+        icon={<Plus size="lg" fill={t.palette.white} />}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New chat`)}
+        accessibilityHint=""
+      />
+
+      <Dialog.Outer
+        control={control}
+        testID="newChatDialog"
+        nativeOptions={{sheet: {snapPoints: ['100%']}}}>
+        <SearchablePeopleList
+          title={_(msg`Start a new chat`)}
+          onSelectChat={onCreateChat}
+        />
+      </Dialog.Outer>
+    </>
+  )
+}
diff --git a/src/components/dms/NewChatDialog/index.tsx b/src/components/dms/dialogs/SearchablePeopleList.tsx
index a6c303043..2c212e56f 100644
--- a/src/components/dms/NewChatDialog/index.tsx
+++ b/src/components/dms/dialogs/SearchablePeopleList.tsx
@@ -16,23 +16,18 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {isWeb} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
 import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
 import {useSession} from '#/state/session'
-import {logEvent} from 'lib/statsig/statsig'
 import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
-import {FAB} from '#/view/com/util/fab/FAB'
-import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, native, useTheme, web} from '#/alf'
 import {Button} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import {TextInput} from '#/components/dms/NewChatDialog/TextInput'
+import {TextInput} from '#/components/dms/dialogs/TextInput'
 import {canBeMessaged} from '#/components/dms/util'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
-import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {Text} from '#/components/Typography'
 
@@ -57,247 +52,12 @@ type Item =
       key: string
     }
 
-export function NewChat({
-  control,
-  onNewChat,
+export function SearchablePeopleList({
+  title,
+  onSelectChat,
 }: {
-  control: Dialog.DialogControlProps
-  onNewChat: (chatId: string) => void
-}) {
-  const t = useTheme()
-  const {_} = useLingui()
-
-  const {mutate: createChat} = useGetConvoForMembers({
-    onSuccess: data => {
-      onNewChat(data.convo.id)
-
-      if (!data.convo.lastMessage) {
-        logEvent('chat:create', {logContext: 'NewChatDialog'})
-      }
-      logEvent('chat:open', {logContext: 'NewChatDialog'})
-    },
-    onError: error => {
-      Toast.show(error.message)
-    },
-  })
-
-  const onCreateChat = useCallback(
-    (did: string) => {
-      control.close(() => createChat([did]))
-    },
-    [control, createChat],
-  )
-
-  return (
-    <>
-      <FAB
-        testID="newChatFAB"
-        onPress={control.open}
-        icon={<Plus size="lg" fill={t.palette.white} />}
-        accessibilityRole="button"
-        accessibilityLabel={_(msg`New chat`)}
-        accessibilityHint=""
-      />
-
-      <Dialog.Outer
-        control={control}
-        testID="newChatDialog"
-        nativeOptions={{sheet: {snapPoints: ['100%']}}}>
-        <SearchablePeopleList onCreateChat={onCreateChat} />
-      </Dialog.Outer>
-    </>
-  )
-}
-
-function ProfileCard({
-  enabled,
-  profile,
-  moderationOpts,
-  onPress,
-}: {
-  enabled: boolean
-  profile: AppBskyActorDefs.ProfileView
-  moderationOpts: ModerationOpts
-  onPress: (did: string) => void
-}) {
-  const t = useTheme()
-  const {_} = useLingui()
-  const moderation = moderateProfile(profile, moderationOpts)
-  const handle = sanitizeHandle(profile.handle, '@')
-  const displayName = sanitizeDisplayName(
-    profile.displayName || sanitizeHandle(profile.handle),
-    moderation.ui('displayName'),
-  )
-
-  const handleOnPress = useCallback(() => {
-    onPress(profile.did)
-  }, [onPress, profile.did])
-
-  return (
-    <Button
-      disabled={!enabled}
-      label={_(msg`Start chat with ${displayName}`)}
-      onPress={handleOnPress}>
-      {({hovered, pressed, focused}) => (
-        <View
-          style={[
-            a.flex_1,
-            a.py_md,
-            a.px_lg,
-            a.gap_md,
-            a.align_center,
-            a.flex_row,
-            !enabled
-              ? {opacity: 0.5}
-              : pressed || focused
-              ? t.atoms.bg_contrast_25
-              : hovered
-              ? t.atoms.bg_contrast_50
-              : t.atoms.bg,
-          ]}>
-          <UserAvatar
-            size={42}
-            avatar={profile.avatar}
-            moderation={moderation.ui('avatar')}
-            type={profile.associated?.labeler ? 'labeler' : 'user'}
-          />
-          <View style={[a.flex_1, a.gap_2xs]}>
-            <Text
-              style={[t.atoms.text, a.font_bold, a.leading_snug]}
-              numberOfLines={1}>
-              {displayName}
-            </Text>
-            <Text style={t.atoms.text_contrast_high} numberOfLines={2}>
-              {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
-            </Text>
-          </View>
-        </View>
-      )}
-    </Button>
-  )
-}
-
-function ProfileCardSkeleton() {
-  const t = useTheme()
-
-  return (
-    <View
-      style={[
-        a.flex_1,
-        a.py_md,
-        a.px_lg,
-        a.gap_md,
-        a.align_center,
-        a.flex_row,
-      ]}>
-      <View
-        style={[
-          a.rounded_full,
-          {width: 42, height: 42},
-          t.atoms.bg_contrast_25,
-        ]}
-      />
-
-      <View style={[a.flex_1, a.gap_sm]}>
-        <View
-          style={[
-            a.rounded_xs,
-            {width: 80, height: 14},
-            t.atoms.bg_contrast_25,
-          ]}
-        />
-        <View
-          style={[
-            a.rounded_xs,
-            {width: 120, height: 10},
-            t.atoms.bg_contrast_25,
-          ]}
-        />
-      </View>
-    </View>
-  )
-}
-
-function Empty({message}: {message: string}) {
-  const t = useTheme()
-  return (
-    <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
-      <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
-        {message}
-      </Text>
-
-      <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
-    </View>
-  )
-}
-
-function SearchInput({
-  value,
-  onChangeText,
-  onEscape,
-  inputRef,
-}: {
-  value: string
-  onChangeText: (text: string) => void
-  onEscape: () => void
-  inputRef: React.RefObject<TextInputType>
-}) {
-  const t = useTheme()
-  const {_} = useLingui()
-  const {
-    state: hovered,
-    onIn: onMouseEnter,
-    onOut: onMouseLeave,
-  } = useInteractionState()
-  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
-  const interacted = hovered || focused
-
-  return (
-    <View
-      {...web({
-        onMouseEnter,
-        onMouseLeave,
-      })}
-      style={[a.flex_row, a.align_center, a.gap_sm]}>
-      <Search
-        size="md"
-        fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
-      />
-
-      <TextInput
-        // @ts-ignore bottom sheet input types issue — esb
-        ref={inputRef}
-        placeholder={_(msg`Search`)}
-        value={value}
-        onChangeText={onChangeText}
-        onFocus={onFocus}
-        onBlur={onBlur}
-        style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
-        placeholderTextColor={t.palette.contrast_500}
-        keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
-        returnKeyType="search"
-        clearButtonMode="while-editing"
-        maxLength={50}
-        onKeyPress={({nativeEvent}) => {
-          if (nativeEvent.key === 'Escape') {
-            onEscape()
-          }
-        }}
-        autoCorrect={false}
-        autoComplete="off"
-        autoCapitalize="none"
-        autoFocus
-        accessibilityLabel={_(msg`Search profiles`)}
-        accessibilityHint={_(msg`Search profiles`)}
-      />
-    </View>
-  )
-}
-
-function SearchablePeopleList({
-  onCreateChat,
-}: {
-  onCreateChat: (did: string) => void
+  title: string
+  onSelectChat: (did: string) => void
 }) {
   const t = useTheme()
   const {_} = useLingui()
@@ -388,7 +148,7 @@ function SearchablePeopleList({
               enabled={item.enabled}
               profile={item.profile}
               moderationOpts={moderationOpts!}
-              onPress={onCreateChat}
+              onPress={onSelectChat}
             />
           )
         }
@@ -402,7 +162,7 @@ function SearchablePeopleList({
           return null
       }
     },
-    [moderationOpts, onCreateChat],
+    [moderationOpts, onSelectChat],
   )
 
   useLayoutEffect(() => {
@@ -464,7 +224,7 @@ function SearchablePeopleList({
               a.leading_tight,
               t.atoms.text_contrast_high,
             ]}>
-            <Trans>Start a new chat</Trans>
+            {title}
           </Text>
         </View>
 
@@ -481,7 +241,16 @@ function SearchablePeopleList({
         </View>
       </View>
     )
-  }, [t, _, control, searchText])
+  }, [
+    t.atoms.border_contrast_low,
+    t.atoms.bg,
+    t.atoms.text_contrast_high,
+    t.palette.contrast_500,
+    _,
+    title,
+    searchText,
+    control,
+  ])
 
   return (
     <Dialog.InnerFlatList
@@ -507,3 +276,188 @@ function SearchablePeopleList({
     />
   )
 }
+
+function ProfileCard({
+  enabled,
+  profile,
+  moderationOpts,
+  onPress,
+}: {
+  enabled: boolean
+  profile: AppBskyActorDefs.ProfileView
+  moderationOpts: ModerationOpts
+  onPress: (did: string) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const moderation = moderateProfile(profile, moderationOpts)
+  const handle = sanitizeHandle(profile.handle, '@')
+  const displayName = sanitizeDisplayName(
+    profile.displayName || sanitizeHandle(profile.handle),
+    moderation.ui('displayName'),
+  )
+
+  const handleOnPress = useCallback(() => {
+    onPress(profile.did)
+  }, [onPress, profile.did])
+
+  return (
+    <Button
+      disabled={!enabled}
+      label={_(msg`Start chat with ${displayName}`)}
+      onPress={handleOnPress}>
+      {({hovered, pressed, focused}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.py_md,
+            a.px_lg,
+            a.gap_md,
+            a.align_center,
+            a.flex_row,
+            !enabled
+              ? {opacity: 0.5}
+              : pressed || focused
+              ? t.atoms.bg_contrast_25
+              : hovered
+              ? t.atoms.bg_contrast_50
+              : t.atoms.bg,
+          ]}>
+          <UserAvatar
+            size={42}
+            avatar={profile.avatar}
+            moderation={moderation.ui('avatar')}
+            type={profile.associated?.labeler ? 'labeler' : 'user'}
+          />
+          <View style={[a.flex_1, a.gap_2xs]}>
+            <Text
+              style={[t.atoms.text, a.font_bold, a.leading_snug]}
+              numberOfLines={1}>
+              {displayName}
+            </Text>
+            <Text style={t.atoms.text_contrast_high} numberOfLines={2}>
+              {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
+            </Text>
+          </View>
+        </View>
+      )}
+    </Button>
+  )
+}
+
+function ProfileCardSkeleton() {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.py_md,
+        a.px_lg,
+        a.gap_md,
+        a.align_center,
+        a.flex_row,
+      ]}>
+      <View
+        style={[
+          a.rounded_full,
+          {width: 42, height: 42},
+          t.atoms.bg_contrast_25,
+        ]}
+      />
+
+      <View style={[a.flex_1, a.gap_sm]}>
+        <View
+          style={[
+            a.rounded_xs,
+            {width: 80, height: 14},
+            t.atoms.bg_contrast_25,
+          ]}
+        />
+        <View
+          style={[
+            a.rounded_xs,
+            {width: 120, height: 10},
+            t.atoms.bg_contrast_25,
+          ]}
+        />
+      </View>
+    </View>
+  )
+}
+
+function Empty({message}: {message: string}) {
+  const t = useTheme()
+  return (
+    <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
+      <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
+        {message}
+      </Text>
+
+      <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
+    </View>
+  )
+}
+
+function SearchInput({
+  value,
+  onChangeText,
+  onEscape,
+  inputRef,
+}: {
+  value: string
+  onChangeText: (text: string) => void
+  onEscape: () => void
+  inputRef: React.RefObject<TextInputType>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const interacted = hovered || focused
+
+  return (
+    <View
+      {...web({
+        onMouseEnter,
+        onMouseLeave,
+      })}
+      style={[a.flex_row, a.align_center, a.gap_sm]}>
+      <Search
+        size="md"
+        fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
+      />
+
+      <TextInput
+        // @ts-ignore bottom sheet input types issue — esb
+        ref={inputRef}
+        placeholder={_(msg`Search`)}
+        value={value}
+        onChangeText={onChangeText}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
+        placeholderTextColor={t.palette.contrast_500}
+        keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
+        returnKeyType="search"
+        clearButtonMode="while-editing"
+        maxLength={50}
+        onKeyPress={({nativeEvent}) => {
+          if (nativeEvent.key === 'Escape') {
+            onEscape()
+          }
+        }}
+        autoCorrect={false}
+        autoComplete="off"
+        autoCapitalize="none"
+        autoFocus
+        accessibilityLabel={_(msg`Search profiles`)}
+        accessibilityHint={_(msg`Search profiles`)}
+      />
+    </View>
+  )
+}
diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx
new file mode 100644
index 000000000..ac475f7c9
--- /dev/null
+++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx
@@ -0,0 +1,52 @@
+import React, {useCallback} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
+import {logEvent} from 'lib/statsig/statsig'
+import * as Toast from '#/view/com/util/Toast'
+import * as Dialog from '#/components/Dialog'
+import {SearchablePeopleList} from './SearchablePeopleList'
+
+export function SendViaChatDialog({
+  control,
+  onSelectChat,
+}: {
+  control: Dialog.DialogControlProps
+  onSelectChat: (chatId: string) => void
+}) {
+  const {_} = useLingui()
+
+  const {mutate: createChat} = useGetConvoForMembers({
+    onSuccess: data => {
+      onSelectChat(data.convo.id)
+
+      if (!data.convo.lastMessage) {
+        logEvent('chat:create', {logContext: 'SendViaChatDialog'})
+      }
+      logEvent('chat:open', {logContext: 'SendViaChatDialog'})
+    },
+    onError: error => {
+      Toast.show(error.message)
+    },
+  })
+
+  const onCreateChat = useCallback(
+    (did: string) => {
+      control.close(() => createChat([did]))
+    },
+    [control, createChat],
+  )
+
+  return (
+    <Dialog.Outer
+      control={control}
+      testID="sendViaChatChatDialog"
+      nativeOptions={{sheet: {snapPoints: ['100%']}}}>
+      <SearchablePeopleList
+        title={_(msg`Send post to...`)}
+        onSelectChat={onCreateChat}
+      />
+    </Dialog.Outer>
+  )
+}
diff --git a/src/components/dms/NewChatDialog/TextInput.tsx b/src/components/dms/dialogs/TextInput.tsx
index b4e77e3e0..b4e77e3e0 100644
--- a/src/components/dms/NewChatDialog/TextInput.tsx
+++ b/src/components/dms/dialogs/TextInput.tsx
diff --git a/src/components/dms/NewChatDialog/TextInput.web.tsx b/src/components/dms/dialogs/TextInput.web.tsx
index 5371a534f..5371a534f 100644
--- a/src/components/dms/NewChatDialog/TextInput.web.tsx
+++ b/src/components/dms/dialogs/TextInput.web.tsx
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 5011aafd7..7504cd83a 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -38,7 +38,7 @@ export type CommonNavigatorParams = {
   AccessibilitySettings: undefined
   Search: {q?: string}
   Hashtag: {tag: string; author?: string}
-  MessagesConversation: {conversation: string}
+  MessagesConversation: {conversation: string; embed?: string}
   MessagesSettings: undefined
 }
 
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 00444c18c..48651b3d9 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -130,10 +130,14 @@ export type LogEvents = {
       | 'AvatarButton'
   }
   'chat:create': {
-    logContext: 'ProfileHeader' | 'NewChatDialog'
+    logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
   }
   'chat:open': {
-    logContext: 'ProfileHeader' | 'NewChatDialog' | 'ChatsList'
+    logContext:
+      | 'ProfileHeader'
+      | 'NewChatDialog'
+      | 'ChatsList'
+      | 'SendViaChatDialog'
   }
 
   'test:all:always': {}
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
index 149188684..c8229f95d 100644
--- a/src/screens/Messages/Conversation/MessageInput.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.tsx
@@ -27,13 +27,20 @@ import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {useSharedInputStyles} from '#/components/forms/TextField'
 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
+import {useExtractEmbedFromFacets} from './MessageInputEmbed'
 
 const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
 
 export function MessageInput({
   onSendMessage,
+  hasEmbed,
+  setEmbed,
+  children,
 }: {
   onSendMessage: (message: string) => void
+  hasEmbed: boolean
+  setEmbed: (embedUrl: string | undefined) => void
+  children?: React.ReactNode
 }) {
   const {_} = useLingui()
   const t = useTheme()
@@ -53,9 +60,10 @@ export function MessageInput({
   const inputRef = useAnimatedRef<TextInput>()
 
   useSaveMessageDraft(message)
+  useExtractEmbedFromFacets(message, setEmbed)
 
   const onSubmit = React.useCallback(() => {
-    if (message.trim() === '') {
+    if (!hasEmbed && message.trim() === '') {
       return
     }
     if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
@@ -66,13 +74,23 @@ export function MessageInput({
     onSendMessage(message)
     playHaptic()
     setMessage('')
+    setEmbed(undefined)
 
     // Pressing the send button causes the text input to lose focus, so we need to
     // re-focus it after sending
     setTimeout(() => {
       inputRef.current?.focus()
     }, 100)
-  }, [message, clearDraft, onSendMessage, playHaptic, _, inputRef])
+  }, [
+    hasEmbed,
+    message,
+    clearDraft,
+    onSendMessage,
+    playHaptic,
+    setEmbed,
+    _,
+    inputRef,
+  ])
 
   useFocusedInputHandler(
     {
@@ -101,6 +119,7 @@ export function MessageInput({
 
   return (
     <View style={[a.px_md, a.pb_sm, a.pt_xs]}>
+      {children}
       <View
         style={[
           a.w_full,
diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx
index a61355e55..b9181774e 100644
--- a/src/screens/Messages/Conversation/MessageInput.web.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.web.tsx
@@ -16,11 +16,18 @@ import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {useSharedInputStyles} from '#/components/forms/TextField'
 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
+import {useExtractEmbedFromFacets} from './MessageInputEmbed'
 
 export function MessageInput({
   onSendMessage,
+  hasEmbed,
+  setEmbed,
+  children,
 }: {
   onSendMessage: (message: string) => void
+  hasEmbed: boolean
+  setEmbed: (embedUrl: string | undefined) => void
+  children?: React.ReactNode
 }) {
   const {isTabletOrDesktop} = useWebMediaQueries()
   const {_} = useLingui()
@@ -35,7 +42,7 @@ export function MessageInput({
   const [textAreaHeight, setTextAreaHeight] = React.useState(38)
 
   const onSubmit = React.useCallback(() => {
-    if (message.trim() === '') {
+    if (!hasEmbed && message.trim() === '') {
       return
     }
     if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
@@ -45,7 +52,8 @@ export function MessageInput({
     clearDraft()
     onSendMessage(message)
     setMessage('')
-  }, [message, onSendMessage, _, clearDraft])
+    setEmbed(undefined)
+  }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
 
   const onKeyDown = React.useCallback(
     (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -87,9 +95,11 @@ export function MessageInput({
   )
 
   useSaveMessageDraft(message)
+  useExtractEmbedFromFacets(message, setEmbed)
 
   return (
     <View style={a.p_sm}>
+      {children}
       <View
         style={[
           a.flex_row,
diff --git a/src/screens/Messages/Conversation/MessageInputEmbed.tsx b/src/screens/Messages/Conversation/MessageInputEmbed.tsx
new file mode 100644
index 000000000..4fdd31bcf
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessageInputEmbed.tsx
@@ -0,0 +1,231 @@
+import React, {useCallback, useEffect, useMemo, useState} from 'react'
+import {LayoutAnimation, View} from 'react-native'
+import {
+  AppBskyEmbedImages,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyFeedPost,
+  AppBskyRichtextFacet,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {RouteProp, useNavigation, useRoute} from '@react-navigation/native'
+
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {makeProfileLink} from '#/lib/routes/links'
+import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {
+  convertBskyAppUrlIfNeeded,
+  isBskyPostUrl,
+  makeRecordUri,
+} from '#/lib/strings/url-helpers'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {usePostQuery} from '#/state/queries/post'
+import {ImageHorzList} from '#/view/com/util/images/ImageHorzList'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Loader} from '#/components/Loader'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
+
+export function useMessageEmbed() {
+  const route =
+    useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
+  const navigation = useNavigation<NavigationProp>()
+  const embedFromParams = route.params.embed
+
+  const [embedUri, setEmbed] = useState(embedFromParams)
+
+  if (embedFromParams && embedUri !== embedFromParams) {
+    setEmbed(embedFromParams)
+  }
+
+  return {
+    embedUri,
+    setEmbed: useCallback(
+      (embedUrl: string | undefined) => {
+        if (!embedUrl) {
+          navigation.setParams({embed: ''})
+          setEmbed(undefined)
+          return
+        }
+
+        if (embedFromParams) return
+
+        const url = convertBskyAppUrlIfNeeded(embedUrl)
+        const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
+        const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
+
+        setEmbed(uri)
+      },
+      [embedFromParams, navigation],
+    ),
+  }
+}
+
+export function useExtractEmbedFromFacets(
+  message: string,
+  setEmbed: (embedUrl: string | undefined) => void,
+) {
+  const rt = new RichTextAPI({text: message})
+  rt.detectFacetsWithoutResolution()
+
+  let uriFromFacet: string | undefined
+
+  for (const facet of rt.facets ?? []) {
+    for (const feature of facet.features) {
+      if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
+        uriFromFacet = feature.uri
+        break
+      }
+    }
+  }
+
+  useEffect(() => {
+    if (uriFromFacet) {
+      setEmbed(uriFromFacet)
+    }
+  }, [uriFromFacet, setEmbed])
+}
+
+export function MessageInputEmbed({
+  embedUri,
+  setEmbed,
+}: {
+  embedUri: string | undefined
+  setEmbed: (embedUrl: string | undefined) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const {data: post, status} = usePostQuery(embedUri)
+
+  const moderationOpts = useModerationOpts()
+  const moderation = useMemo(
+    () =>
+      moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
+    [moderationOpts, post],
+  )
+
+  const {rt, record} = useMemo(() => {
+    if (
+      post &&
+      AppBskyFeedPost.isRecord(post.record) &&
+      AppBskyFeedPost.validateRecord(post.record).success
+    ) {
+      return {
+        rt: new RichTextAPI({
+          text: post.record.text,
+          facets: post.record.facets,
+        }),
+        record: post.record,
+      }
+    }
+
+    return {rt: undefined, record: undefined}
+  }, [post])
+
+  if (!embedUri) {
+    return null
+  }
+
+  let content = null
+  switch (status) {
+    case 'pending':
+      content = (
+        <View
+          style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
+          <Loader />
+        </View>
+      )
+      break
+    case 'error':
+      content = (
+        <View
+          style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
+          <Text style={a.text_center}>Could not fetch post</Text>
+        </View>
+      )
+      break
+    case 'success':
+      const itemUrip = new AtUri(post.uri)
+      const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
+
+      if (!post || !moderation || !rt || !record) {
+        return null
+      }
+
+      const images = AppBskyEmbedImages.isView(post.embed)
+        ? post.embed.images
+        : AppBskyEmbedRecordWithMedia.isView(post.embed) &&
+          AppBskyEmbedImages.isView(post.embed.media)
+        ? post.embed.media.images
+        : undefined
+
+      content = (
+        <View
+          style={[
+            a.flex_1,
+            t.atoms.bg,
+            t.atoms.border_contrast_low,
+            a.rounded_md,
+            a.border,
+            a.p_sm,
+            a.mb_sm,
+          ]}
+          pointerEvents="none">
+          <PostMeta
+            showAvatar
+            author={post.author}
+            moderation={moderation}
+            authorHasWarning={!!post.author.labels?.length}
+            timestamp={post.indexedAt}
+            postHref={itemHref}
+            style={a.flex_0}
+          />
+          <ContentHider modui={moderation.ui('contentView')}>
+            <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
+            {rt.text && (
+              <View style={a.mt_xs}>
+                <RichText
+                  enableTags
+                  testID="postText"
+                  value={rt}
+                  style={[a.text_sm, t.atoms.text_contrast_high]}
+                  authorHandle={post.author.handle}
+                  numberOfLines={3}
+                />
+              </View>
+            )}
+            {images && images?.length > 0 && (
+              <ImageHorzList images={images} style={a.mt_xs} />
+            )}
+          </ContentHider>
+        </View>
+      )
+      break
+  }
+
+  return (
+    <View style={[a.flex_row, a.gap_sm]}>
+      {content}
+      <Button
+        label={_(msg`Remove embed`)}
+        onPress={() => {
+          LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+          setEmbed(undefined)
+        }}
+        size="tiny"
+        variant="solid"
+        color="secondary"
+        shape="round">
+        <ButtonIcon icon={X} />
+      </Button>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index d6aa06a1c..e6f657b49 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -15,9 +15,11 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
 
-import {getPostAsQuote} from '#/lib/link-meta/bsky'
 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
-import {isBskyPostUrl} from '#/lib/strings/url-helpers'
+import {
+  convertBskyAppUrlIfNeeded,
+  isBskyPostUrl,
+} from '#/lib/strings/url-helpers'
 import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
 import {isConvoActive, useConvoActive} from '#/state/messages/convo'
@@ -36,6 +38,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
 import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
+import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
 
 function MaybeLoader({isLoading}: {isLoading: boolean}) {
   return (
@@ -85,6 +88,7 @@ export function MessagesList({
   const convoState = useConvoActive()
   const agent = useAgent()
   const getPost = useGetPost()
+  const {embedUri, setEmbed} = useMessageEmbed()
 
   const flatListRef = useAnimatedRef<FlatList>()
 
@@ -277,25 +281,10 @@ export function MessagesList({
       rt.detectFacetsWithoutResolution()
 
       let embed: AppBskyEmbedRecord.Main | undefined
-      // find the first link facet that is a link to a post
-      const postLinkFacet = rt.facets?.find(facet => {
-        return facet.features.find(feature => {
-          if (AppBskyRichtextFacet.isLink(feature)) {
-            return isBskyPostUrl(feature.uri)
-          }
-          return false
-        })
-      })
-
-      // if we found a post link, get the post and embed it
-      if (postLinkFacet) {
-        const postLink = postLinkFacet.features.find(
-          AppBskyRichtextFacet.isLink,
-        )
-        if (!postLink) return
 
+      if (embedUri) {
         try {
-          const post = await getPostAsQuote(getPost, postLink.uri)
+          const post = await getPost({uri: embedUri})
           if (post) {
             embed = {
               $type: 'app.bsky.embed.record',
@@ -305,24 +294,43 @@ export function MessagesList({
               },
             }
 
-            // remove the post link from the text
-            rt.delete(
-              postLinkFacet.index.byteStart,
-              postLinkFacet.index.byteEnd,
-            )
-
-            // re-trim the text, now that we've removed the post link
-            //
-            // if the post link is at the start of the text, we don't want to leave a leading space
-            // so trim on both sides
-            if (postLinkFacet.index.byteStart === 0) {
-              rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
-            } else {
-              // otherwise just trim the end
-              rt = new RichText(
-                {text: rt.text.trimEnd()},
-                {cleanNewlines: true},
+            // look for the embed uri in the facets, so we can remove it from the text
+            const postLinkFacet = rt.facets?.find(facet => {
+              return facet.features.find(feature => {
+                if (AppBskyRichtextFacet.isLink(feature)) {
+                  if (isBskyPostUrl(feature.uri)) {
+                    const url = convertBskyAppUrlIfNeeded(feature.uri)
+                    const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
+
+                    // this might have a handle instead of a DID
+                    // so just compare the rkey - not particularly dangerous
+                    return post.uri.endsWith(rkey)
+                  }
+                }
+                return false
+              })
+            })
+
+            if (postLinkFacet) {
+              // remove the post link from the text
+              rt.delete(
+                postLinkFacet.index.byteStart,
+                postLinkFacet.index.byteEnd,
               )
+
+              // re-trim the text, now that we've removed the post link
+              //
+              // if the post link is at the start of the text, we don't want to leave a leading space
+              // so trim on both sides
+              if (postLinkFacet.index.byteStart === 0) {
+                rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
+              } else {
+                // otherwise just trim the end
+                rt = new RichText(
+                  {text: rt.text.trimEnd()},
+                  {cleanNewlines: true},
+                )
+              }
             }
           }
         } catch (error) {
@@ -345,7 +353,7 @@ export function MessagesList({
         embed,
       })
     },
-    [agent, convoState, getPost, hasScrolled, setHasScrolled],
+    [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
   )
 
   // -- List layout changes (opening emoji keyboard, etc.)
@@ -420,7 +428,12 @@ export function MessagesList({
             {isConvoActive(convoState) &&
               !convoState.isFetchingHistory &&
               convoState.items.length === 0 && <ChatEmptyPill />}
-            <MessageInput onSendMessage={onSendMessage} />
+            <MessageInput
+              onSendMessage={onSendMessage}
+              hasEmbed={!!embedUri}
+              setEmbed={setEmbed}>
+              <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
+            </MessageInput>
           </>
         )}
       </KeyboardStickyView>
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index 7c67c59d3..0b1fe2a95 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/List/index.tsx
@@ -21,8 +21,8 @@ import {CenteredView} from '#/view/com/util/Views'
 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {DialogControlProps, useDialogControl} from '#/components/Dialog'
+import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
 import {MessagesNUX} from '#/components/dms/MessagesNUX'
-import {NewChat} from '#/components/dms/NewChatDialog'
 import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
 import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index 9850124c9..de2605b5a 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -1018,6 +1018,7 @@ export class Convo {
         key: m.id,
         message: {
           ...m.message,
+          embed: undefined,
           $type: 'chat.bsky.convo.defs#messageView',
           id: nanoid(),
           rev: '__fake__',
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index f27628d69..794f48eb1 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -18,7 +18,16 @@ export function usePostQuery(uri: string | undefined) {
   return useQuery<AppBskyFeedDefs.PostView>({
     queryKey: RQKEY(uri || ''),
     async queryFn() {
-      const res = await agent.getPosts({uris: [uri!]})
+      const urip = new AtUri(uri!)
+
+      if (!urip.host.startsWith('did:')) {
+        const res = await agent.resolveHandle({
+          handle: urip.host,
+        })
+        urip.host = res.data.did
+      }
+
+      const res = await agent.getPosts({uris: [urip.toString()]})
       if (res.success && res.data.posts[0]) {
         return res.data.posts[0]
       }
@@ -47,7 +56,7 @@ export function useGetPost() {
           }
 
           const res = await agent.getPosts({
-            uris: [urip.toString()!],
+            uris: [urip.toString()],
           })
 
           if (res.success && res.data.posts[0]) {
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index a5cc60fd8..4b50946a4 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -451,7 +451,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
     return (
       <>
         {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
-        {images && images?.length > 0 && (
+        {images && images.length > 0 && (
           <ImageHorzList images={images} style={styles.additionalPostImages} />
         )}
       </>
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index cd82ec98f..945cf5e59 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -12,12 +12,12 @@ import {
   AtUri,
   RichText as RichTextAPI,
 } from '@atproto/api'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
 import {makeProfileLink} from '#/lib/routes/links'
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {getTranslatorLink} from '#/locale/helpers'
 import {logger} from '#/logger'
@@ -37,6 +37,7 @@ import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
 import {useDialogControl} from '#/components/Dialog'
 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 import {EmbedDialog} from '#/components/dialogs/Embed'
+import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
@@ -49,6 +50,7 @@ import {
 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
@@ -102,13 +104,14 @@ let PostDropdownBtn = ({
   const {hidePost} = useHiddenPostsApi()
   const feedFeedback = useFeedFeedbackContext()
   const openLink = useOpenLink()
-  const navigation = useNavigation()
+  const navigation = useNavigation<NavigationProp>()
   const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
   const reportDialogControl = useReportDialogControl()
   const deletePromptControl = useDialogControl()
   const hidePromptControl = useDialogControl()
   const loggedOutWarningPromptControl = useDialogControl()
   const embedPostControl = useDialogControl()
+  const sendViaChatControl = useDialogControl()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
@@ -229,6 +232,16 @@ let PostDropdownBtn = ({
     Toast.show('Feedback sent!')
   }, [feedFeedback, postUri, postFeedContext])
 
+  const onSelectChatToShareTo = React.useCallback(
+    (conversation: string) => {
+      navigation.navigate('MessagesConversation', {
+        conversation,
+        embed: postUri,
+      })
+    },
+    [navigation, postUri],
+  )
+
   const canEmbed = isWeb && gtMobile && !hideInPWI
 
   return (
@@ -280,6 +293,18 @@ let PostDropdownBtn = ({
               </>
             )}
 
+            {hasSession && (
+              <Menu.Item
+                testID="postDropdownSendViaDMBtn"
+                label={_(msg`Send via direct message`)}
+                onPress={sendViaChatControl.open}>
+                <Menu.ItemText>
+                  <Trans>Send via direct message</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={Send} position="right" />
+              </Menu.Item>
+            )}
+
             <Menu.Item
               testID="postDropdownShareBtn"
               label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
@@ -449,6 +474,11 @@ let PostDropdownBtn = ({
           timestamp={timestamp}
         />
       )}
+
+      <SendViaChatDialog
+        control={sendViaChatControl}
+        onSelectChat={onSelectChatToShareTo}
+      />
     </EventStopper>
   )
 }
diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx
index e37f8af1b..12eef14f7 100644
--- a/src/view/com/util/images/ImageHorzList.tsx
+++ b/src/view/com/util/images/ImageHorzList.tsx
@@ -27,11 +27,14 @@ export function ImageHorzList({images, style}: Props) {
 }
 
 const styles = StyleSheet.create({
-  flexRow: {flexDirection: 'row'},
+  flexRow: {
+    flexDirection: 'row',
+    gap: 5,
+  },
   image: {
-    width: 100,
-    height: 100,
+    maxWidth: 100,
+    aspectRatio: 1,
+    flex: 1,
     borderRadius: 4,
-    marginRight: 5,
   },
 })
diff --git a/yarn.lock b/yarn.lock
index 3e1246d92..ae18bfbec 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,10 +34,10 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
-"@atproto/api@^0.12.13":
-  version "0.12.13"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.13.tgz#269d6c57ea894e23f20b28bd3cbfed944bd28528"
-  integrity sha512-pRSID6w8AUiZJoCxgctMPRTSGVFHq7wphAnxEbRLBP3OQ1g+BRZUcqFw+e+17Pd3wrc8VImjiD4HCWtCJvCx3w==
+"@atproto/api@^0.12.14":
+  version "0.12.14"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.14.tgz#81252fd166ec8fe950056531e690d563437720fa"
+  integrity sha512-ZPh/afoRjFEQDQgMZW2FQiG5CDUifY7SxBqI0zVJUwed8Zi6fqYzGYM8fcDvD8yJfflRCqRxUE72g5fKiA1zAQ==
   dependencies:
     "@atproto/common-web" "^0.3.0"
     "@atproto/lexicon" "^0.4.0"
@@ -22564,12 +22564,12 @@ zod-validation-error@^3.0.3:
   resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af"
   integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==
 
-zod@^3.14.2, zod@^3.20.2, zod@^3.21.4:
+zod@^3.14.2, zod@^3.20.2:
   version "3.22.2"
   resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
   integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
 
-zod@^3.22.4:
+zod@^3.21.4, zod@^3.22.4:
   version "3.23.8"
   resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
   integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==