about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-04-16 14:29:32 -0700
committerGitHub <noreply@github.com>2024-04-16 14:29:32 -0700
commit046e11de31a9e6ddda32811b1efab52f9c221616 (patch)
treea8893b51c48002a3c6ad166f18a0fcf5f87d7c88 /src
parent71c427cea86db31f7bd6c11cd460c0d7a48c0b06 (diff)
downloadvoidsky-046e11de31a9e6ddda32811b1efab52f9c221616.tar.zst
Automatically add a link card for URLs in the composer (#3566)
* automatically add a link card for urls in the composer

simplify was paste check

use a set

simplify the cross platform reuse

web implementation

remove log

pasting in the middle of a block of text

proper regex

dont re-add immediately after paste and remove

don't use `byteIndex`

lfg

automatically add link card

* `mayBePaste`

* remove accidentally pasted url from comment
Diffstat (limited to 'src')
-rw-r--r--src/view/com/composer/Composer.tsx33
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx64
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx67
-rw-r--r--src/view/com/composer/text-input/text-input-util.ts59
4 files changed, 144 insertions, 79 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 2d5c9ee7f..f8af6ce1b 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -42,7 +42,6 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError} from 'lib/strings/errors'
 import {insertMentionAt} from 'lib/strings/mention-manip'
 import {shortenLinks} from 'lib/strings/rich-text-manip'
-import {toShortUrl} from 'lib/strings/url-helpers'
 import {colors, gradients, s} from 'lib/styles'
 import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
 import {useDialogStateControlContext} from 'state/dialogs'
@@ -119,7 +118,6 @@ export const ComposePost = observer(function ComposePost({
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
   const [labels, setLabels] = useState<string[]>([])
   const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
-  const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
   const gallery = useMemo(
     () => new GalleryModel(initImageUris),
     [initImageUris],
@@ -189,11 +187,12 @@ export const ComposePost = observer(function ComposePost({
     }
   }, [onEscape, isModalActive])
 
-  const onPressAddLinkCard = useCallback(
+  const onNewLink = useCallback(
     (uri: string) => {
+      if (extLink != null) return
       setExtLink({uri, isLoading: true})
     },
-    [setExtLink],
+    [extLink, setExtLink],
   )
 
   const onPhotoPasted = useCallback(
@@ -430,12 +429,11 @@ export const ComposePost = observer(function ComposePost({
               ref={textInput}
               richtext={richtext}
               placeholder={selectTextInputPlaceholder}
-              suggestedLinks={suggestedLinks}
               autoFocus={true}
               setRichText={setRichText}
               onPhotoPasted={onPhotoPasted}
               onPressPublish={onPressPublish}
-              onSuggestedLinksChanged={setSuggestedLinks}
+              onNewLink={onNewLink}
               onError={setError}
               accessible={true}
               accessibilityLabel={_(msg`Write post`)}
@@ -458,29 +456,6 @@ export const ComposePost = observer(function ComposePost({
             </View>
           ) : undefined}
         </ScrollView>
-        {!extLink && suggestedLinks.size > 0 ? (
-          <View style={s.mb5}>
-            {Array.from(suggestedLinks)
-              .slice(0, 3)
-              .map(url => (
-                <TouchableOpacity
-                  key={`suggested-${url}`}
-                  testID="addLinkCardBtn"
-                  style={[pal.borderDark, styles.addExtLinkBtn]}
-                  onPress={() => onPressAddLinkCard(url)}
-                  accessibilityRole="button"
-                  accessibilityLabel={_(msg`Add link card`)}
-                  accessibilityHint={_(
-                    msg`Creates a card with a thumbnail. The card links to ${url}`,
-                  )}>
-                  <Text style={pal.text}>
-                    <Trans>Add link card:</Trans>{' '}
-                    <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text>
-                  </Text>
-                </TouchableOpacity>
-              ))}
-          </View>
-        ) : null}
         <SuggestedLanguage text={richtext.text} />
         <View style={[pal.border, styles.bottomBar]}>
           {canSelectImages ? (
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 20be585c2..aad1d5e01 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -1,10 +1,10 @@
 import React, {
+  ComponentProps,
   forwardRef,
   useCallback,
-  useRef,
   useMemo,
+  useRef,
   useState,
-  ComponentProps,
 } from 'react'
 import {
   NativeSyntheticEvent,
@@ -13,22 +13,26 @@ import {
   TextInputSelectionChangeEventData,
   View,
 } from 'react-native'
+import {AppBskyRichtextFacet, RichText} from '@atproto/api'
 import PasteInput, {
   PastedFile,
   PasteInputRef,
 } from '@mattermost/react-native-paste-input'
-import {AppBskyRichtextFacet, RichText} from '@atproto/api'
-import isEqual from 'lodash.isequal'
-import {Autocomplete} from './mobile/Autocomplete'
-import {Text} from 'view/com/util/text/Text'
+
+import {POST_IMG_MAX} from 'lib/constants'
+import {usePalette} from 'lib/hooks/usePalette'
+import {downloadAndResize} from 'lib/media/manip'
+import {isUriImage} from 'lib/media/util'
 import {cleanError} from 'lib/strings/errors'
 import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
-import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
-import {isUriImage} from 'lib/media/util'
-import {downloadAndResize} from 'lib/media/manip'
-import {POST_IMG_MAX} from 'lib/constants'
 import {isIOS} from 'platform/detection'
+import {
+  addLinkCardIfNecessary,
+  findIndexInText,
+} from 'view/com/composer/text-input/text-input-util'
+import {Text} from 'view/com/util/text/Text'
+import {Autocomplete} from './mobile/Autocomplete'
 
 export interface TextInputRef {
   focus: () => void
@@ -39,11 +43,10 @@ export interface TextInputRef {
 interface TextInputProps extends ComponentProps<typeof RNTextInput> {
   richtext: RichText
   placeholder: string
-  suggestedLinks: Set<string>
   setRichText: (v: RichText | ((v: RichText) => RichText)) => void
   onPhotoPasted: (uri: string) => void
   onPressPublish: (richtext: RichText) => Promise<void>
-  onSuggestedLinksChanged: (uris: Set<string>) => void
+  onNewLink: (uri: string) => void
   onError: (err: string) => void
 }
 
@@ -56,10 +59,9 @@ export const TextInput = forwardRef(function TextInputImpl(
   {
     richtext,
     placeholder,
-    suggestedLinks,
     setRichText,
     onPhotoPasted,
-    onSuggestedLinksChanged,
+    onNewLink,
     onError,
     ...props
   }: TextInputProps,
@@ -70,6 +72,8 @@ export const TextInput = forwardRef(function TextInputImpl(
   const textInputSelection = useRef<Selection>({start: 0, end: 0})
   const theme = useTheme()
   const [autocompletePrefix, setAutocompletePrefix] = useState('')
+  const prevLength = React.useRef(richtext.length)
+  const prevAddedLinks = useRef(new Set<string>())
 
   React.useImperativeHandle(ref, () => ({
     focus: () => textInput.current?.focus(),
@@ -92,6 +96,8 @@ export const TextInput = forwardRef(function TextInputImpl(
        * @see https://github.com/bluesky-social/social-app/issues/929
        */
       setTimeout(async () => {
+        const mayBePaste = newText.length > prevLength.current + 1
+
         const newRt = new RichText({text: newText})
         newRt.detectFacetsWithoutResolution()
         setRichText(newRt)
@@ -106,8 +112,6 @@ export const TextInput = forwardRef(function TextInputImpl(
           setAutocompletePrefix('')
         }
 
-        const set: Set<string> = new Set()
-
         if (newRt.facets) {
           for (const facet of newRt.facets) {
             for (const feature of facet.features) {
@@ -126,26 +130,32 @@ export const TextInput = forwardRef(function TextInputImpl(
                     onPhotoPasted(res.path)
                   }
                 } else {
-                  set.add(feature.uri)
+                  const cursorLocation = textInputSelection.current.end
+
+                  addLinkCardIfNecessary({
+                    uri: feature.uri,
+                    newText,
+                    cursorLocation,
+                    mayBePaste,
+                    onNewLink,
+                    prevAddedLinks: prevAddedLinks.current,
+                  })
                 }
               }
             }
           }
         }
 
-        if (!isEqual(set, suggestedLinks)) {
-          onSuggestedLinksChanged(set)
+        for (const uri of prevAddedLinks.current.keys()) {
+          if (findIndexInText(uri, newText) === -1) {
+            prevAddedLinks.current.delete(uri)
+          }
         }
+
+        prevLength.current = newText.length
       }, 1)
     },
-    [
-      setRichText,
-      autocompletePrefix,
-      setAutocompletePrefix,
-      suggestedLinks,
-      onSuggestedLinksChanged,
-      onPhotoPasted,
-    ],
+    [setRichText, autocompletePrefix, onPhotoPasted, prevAddedLinks, onNewLink],
   )
 
   const onPaste = useCallback(
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index c62d11201..1038fe5db 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -1,28 +1,32 @@
-import React from 'react'
+import React, {useRef} from 'react'
 import {StyleSheet, View} from 'react-native'
-import {RichText, AppBskyRichtextFacet} from '@atproto/api'
-import EventEmitter from 'eventemitter3'
-import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {AppBskyRichtextFacet, RichText} from '@atproto/api'
+import {Trans} from '@lingui/macro'
 import {Document} from '@tiptap/extension-document'
-import History from '@tiptap/extension-history'
 import Hardbreak from '@tiptap/extension-hard-break'
+import History from '@tiptap/extension-history'
 import {Mention} from '@tiptap/extension-mention'
 import {Paragraph} from '@tiptap/extension-paragraph'
 import {Placeholder} from '@tiptap/extension-placeholder'
 import {Text as TiptapText} from '@tiptap/extension-text'
-import isEqual from 'lodash.isequal'
-import {createSuggestion} from './web/Autocomplete'
-import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
-import {isUriImage, blobToDataUri} from 'lib/media/util'
-import {Emoji} from './web/EmojiPicker.web'
-import {LinkDecorator} from './web/LinkDecorator'
 import {generateJSON} from '@tiptap/html'
-import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
+import EventEmitter from 'eventemitter3'
+
 import {usePalette} from '#/lib/hooks/usePalette'
+import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
+import {blobToDataUri, isUriImage} from 'lib/media/util'
+import {
+  addLinkCardIfNecessary,
+  findIndexInText,
+} from 'view/com/composer/text-input/text-input-util'
 import {Portal} from '#/components/Portal'
 import {Text} from '../../util/text/Text'
-import {Trans} from '@lingui/macro'
-import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {createSuggestion} from './web/Autocomplete'
+import {Emoji} from './web/EmojiPicker.web'
+import {LinkDecorator} from './web/LinkDecorator'
 import {TagDecorator} from './web/TagDecorator'
 
 export interface TextInputRef {
@@ -38,7 +42,7 @@ interface TextInputProps {
   setRichText: (v: RichText | ((v: RichText) => RichText)) => void
   onPhotoPasted: (uri: string) => void
   onPressPublish: (richtext: RichText) => Promise<void>
-  onSuggestedLinksChanged: (uris: Set<string>) => void
+  onNewLink: (uri: string) => void
   onError: (err: string) => void
 }
 
@@ -48,16 +52,17 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   {
     richtext,
     placeholder,
-    suggestedLinks,
     setRichText,
     onPhotoPasted,
     onPressPublish,
-    onSuggestedLinksChanged,
+    onNewLink,
   }: // onError, TODO
   TextInputProps,
   ref,
 ) {
   const autocomplete = useActorAutocompleteFn()
+  const prevLength = React.useRef(0)
+  const prevAddedLinks = useRef(new Set<string>())
 
   const pal = usePalette('default')
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
@@ -180,26 +185,42 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       },
       onUpdate({editor: editorProp}) {
         const json = editorProp.getJSON()
+        const newText = editorJsonToText(json).trimEnd()
+        const mayBePaste = newText.length > prevLength.current + 1
 
-        const newRt = new RichText({text: editorJsonToText(json).trimEnd()})
+        const newRt = new RichText({text: newText})
         newRt.detectFacetsWithoutResolution()
         setRichText(newRt)
 
-        const set: Set<string> = new Set()
-
         if (newRt.facets) {
           for (const facet of newRt.facets) {
             for (const feature of facet.features) {
               if (AppBskyRichtextFacet.isLink(feature)) {
-                set.add(feature.uri)
+                // The TipTap editor shows the position as being one character ahead, as if the start index is 1.
+                // Subtracting 1 from the pos gives us the same behavior as the native impl.
+                let cursorLocation = editor?.state.selection.$anchor.pos ?? 1
+                cursorLocation -= 1
+
+                addLinkCardIfNecessary({
+                  uri: feature.uri,
+                  newText,
+                  cursorLocation,
+                  mayBePaste,
+                  onNewLink,
+                  prevAddedLinks: prevAddedLinks.current,
+                })
               }
             }
           }
         }
 
-        if (!isEqual(set, suggestedLinks)) {
-          onSuggestedLinksChanged(set)
+        for (const uri of prevAddedLinks.current.keys()) {
+          if (findIndexInText(uri, newText) === -1) {
+            prevAddedLinks.current.delete(uri)
+          }
         }
+
+        prevLength.current = newText.length
       },
     },
     [modeClass],
diff --git a/src/view/com/composer/text-input/text-input-util.ts b/src/view/com/composer/text-input/text-input-util.ts
new file mode 100644
index 000000000..8119e429c
--- /dev/null
+++ b/src/view/com/composer/text-input/text-input-util.ts
@@ -0,0 +1,59 @@
+export function addLinkCardIfNecessary({
+  uri,
+  newText,
+  cursorLocation,
+  mayBePaste,
+  onNewLink,
+  prevAddedLinks,
+}: {
+  uri: string
+  newText: string
+  cursorLocation: number
+  mayBePaste: boolean
+  onNewLink: (uri: string) => void
+  prevAddedLinks: Set<string>
+}) {
+  // It would be cool if we could just use facet.index.byteEnd, but you know... *upside down smiley*
+  const lastCharacterPosition = findIndexInText(uri, newText) + uri.length
+
+  // If the text being added is not from a paste, then we should only check if the cursor is one
+  // position ahead of the last character. However, if it is a paste we need to check both if it's
+  // the same position _or_ one position ahead. That is because iOS will add a space after a paste if
+  // pasting into the middle of a sentence!
+  const cursorLocationIsOkay =
+    cursorLocation === lastCharacterPosition + 1 || mayBePaste
+
+  // Checking previouslyAddedLinks keeps a card from getting added over and over i.e.
+  // Link card added -> Remove link card -> Press back space -> Press space -> Link card added -> and so on
+
+  // We use the isValidUrl regex below because we don't want to add embeds only if the url is valid, i.e.
+  // http://facebook is a valid url, but that doesn't mean we want to embed it. We should only embed if
+  // the url is a valid url _and_ domain. new URL() won't work for this check.
+  const shouldCheck =
+    cursorLocationIsOkay && !prevAddedLinks.has(uri) && isValidUrlAndDomain(uri)
+
+  if (shouldCheck) {
+    onNewLink(uri)
+    prevAddedLinks.add(uri)
+  }
+}
+
+// https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url
+// question credit Muhammad Imran Tariq https://stackoverflow.com/users/420613/muhammad-imran-tariq
+// answer credit Christian David https://stackoverflow.com/users/967956/christian-david
+function isValidUrlAndDomain(value: string) {
+  return /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(
+    value,
+  )
+}
+
+export function findIndexInText(term: string, text: string) {
+  // This should find patterns like:
+  // HELLO SENTENCE http://google.com/ HELLO
+  // HELLO SENTENCE http://google.com HELLO
+  // http://google.com/ HELLO.
+  // http://google.com/.
+  const pattern = new RegExp(`\\b(${term})(?![/w])`, 'i')
+  const match = pattern.exec(text)
+  return match ? match.index : -1
+}