about summary refs log tree commit diff
path: root/src/screens/Messages/Conversation
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Messages/Conversation')
-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
4 files changed, 315 insertions, 42 deletions
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>