From 8ec3d8c76e4ec94628ecb8b85eab1cbf83dcefe1 Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Wed, 24 Apr 2024 17:30:44 +0100
Subject: [PATCH] Rewrite the link detection (#3687)

* Rewrite the link detection

* Handle parens and colons
---
 .../com/composer/text-input/TextInput.tsx     |  36 +++--
 .../com/composer/text-input/TextInput.web.tsx |  38 +++---
 .../composer/text-input/text-input-util.ts    | 125 +++++++++++-------
 3 files changed, 111 insertions(+), 88 deletions(-)

diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index aad1d5e0..cb16e3c6 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -28,8 +28,8 @@ import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
 import {useTheme} from 'lib/ThemeContext'
 import {isIOS} from 'platform/detection'
 import {
-  addLinkCardIfNecessary,
-  findIndexInText,
+  LinkFacetMatch,
+  suggestLinkCardUri,
 } from 'view/com/composer/text-input/text-input-util'
 import {Text} from 'view/com/util/text/Text'
 import {Autocomplete} from './mobile/Autocomplete'
@@ -73,7 +73,6 @@ export const TextInput = forwardRef(function TextInputImpl(
   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(),
@@ -83,6 +82,8 @@ export const TextInput = forwardRef(function TextInputImpl(
     getCursorPosition: () => undefined, // Not implemented on native
   }))
 
+  const pastSuggestedUris = useRef(new Set<string>())
+  const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
   const onChangeText = useCallback(
     (newText: string) => {
       /*
@@ -112,6 +113,7 @@ export const TextInput = forwardRef(function TextInputImpl(
           setAutocompletePrefix('')
         }
 
+        const nextDetectedUris = new Map<string, LinkFacetMatch>()
         if (newRt.facets) {
           for (const facet of newRt.facets) {
             for (const feature of facet.features) {
@@ -130,32 +132,26 @@ export const TextInput = forwardRef(function TextInputImpl(
                     onPhotoPasted(res.path)
                   }
                 } else {
-                  const cursorLocation = textInputSelection.current.end
-
-                  addLinkCardIfNecessary({
-                    uri: feature.uri,
-                    newText,
-                    cursorLocation,
-                    mayBePaste,
-                    onNewLink,
-                    prevAddedLinks: prevAddedLinks.current,
-                  })
+                  nextDetectedUris.set(feature.uri, {facet, rt: newRt})
                 }
               }
             }
           }
         }
-
-        for (const uri of prevAddedLinks.current.keys()) {
-          if (findIndexInText(uri, newText) === -1) {
-            prevAddedLinks.current.delete(uri)
-          }
+        const suggestedUri = suggestLinkCardUri(
+          mayBePaste,
+          nextDetectedUris,
+          prevDetectedUris.current,
+          pastSuggestedUris.current,
+        )
+        prevDetectedUris.current = nextDetectedUris
+        if (suggestedUri) {
+          onNewLink(suggestedUri)
         }
-
         prevLength.current = newText.length
       }, 1)
     },
-    [setRichText, autocompletePrefix, onPhotoPasted, prevAddedLinks, onNewLink],
+    [setRichText, autocompletePrefix, onPhotoPasted, 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 1b5d7a82..7f8dc2ed 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -19,8 +19,8 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {blobToDataUri, isUriImage} from 'lib/media/util'
 import {
-  addLinkCardIfNecessary,
-  findIndexInText,
+  LinkFacetMatch,
+  suggestLinkCardUri,
 } from 'view/com/composer/text-input/text-input-util'
 import {Portal} from '#/components/Portal'
 import {Text} from '../../util/text/Text'
@@ -61,8 +61,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   ref,
 ) {
   const autocomplete = useActorAutocompleteFn()
-  const prevAddedLinks = useRef(new Set<string>())
-
   const pal = usePalette('default')
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
 
@@ -143,6 +141,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     }
   }, [setIsDropping])
 
+  const pastSuggestedUris = useRef(new Set<string>())
+  const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
   const editor = useEditor(
     {
       extensions,
@@ -185,38 +185,32 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       onUpdate({editor: editorProp}) {
         const json = editorProp.getJSON()
         const newText = editorJsonToText(json)
-        const mayBePaste = window.event?.type === 'paste'
+        const isPaste = window.event?.type === 'paste'
 
         const newRt = new RichText({text: newText})
         newRt.detectFacetsWithoutResolution()
         setRichText(newRt)
 
+        const nextDetectedUris = new Map<string, LinkFacetMatch>()
         if (newRt.facets) {
           for (const facet of newRt.facets) {
             for (const feature of facet.features) {
               if (AppBskyRichtextFacet.isLink(feature)) {
-                // 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,
-                })
+                nextDetectedUris.set(feature.uri, {facet, rt: newRt})
               }
             }
           }
         }
 
-        for (const uri of prevAddedLinks.current.keys()) {
-          if (findIndexInText(uri, newText) === -1) {
-            prevAddedLinks.current.delete(uri)
-          }
+        const suggestedUri = suggestLinkCardUri(
+          isPaste,
+          nextDetectedUris,
+          prevDetectedUris.current,
+          pastSuggestedUris.current,
+        )
+        prevDetectedUris.current = nextDetectedUris
+        if (suggestedUri) {
+          onNewLink(suggestedUri)
         }
       },
     },
diff --git a/src/view/com/composer/text-input/text-input-util.ts b/src/view/com/composer/text-input/text-input-util.ts
index 8119e429..cbe8ef6a 100644
--- a/src/view/com/composer/text-input/text-input-util.ts
+++ b/src/view/com/composer/text-input/text-input-util.ts
@@ -1,41 +1,85 @@
-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
+import {AppBskyRichtextFacet, RichText} from '@atproto/api'
 
-  // 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
+export type LinkFacetMatch = {
+  rt: RichText
+  facet: AppBskyRichtextFacet.Main
+}
 
-  // 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)
+export function suggestLinkCardUri(
+  mayBePaste: boolean,
+  nextDetectedUris: Map<string, LinkFacetMatch>,
+  prevDetectedUris: Map<string, LinkFacetMatch>,
+  pastSuggestedUris: Set<string>,
+): string | undefined {
+  const suggestedUris = new Set<string>()
+  for (const [uri, nextMatch] of nextDetectedUris) {
+    if (!isValidUrlAndDomain(uri)) {
+      continue
+    }
+    if (pastSuggestedUris.has(uri)) {
+      // Don't suggest already added or already dismissed link cards.
+      continue
+    }
+    if (mayBePaste) {
+      // Immediately add the pasted link without waiting to type more.
+      suggestedUris.add(uri)
+      continue
+    }
+    const prevMatch = prevDetectedUris.get(uri)
+    if (!prevMatch) {
+      // If the same exact link wasn't already detected during the last keystroke,
+      // it means you're probably still typing it. Disregard until it stabilizes.
+      continue
+    }
+    const prevTextAfterUri = prevMatch.rt.unicodeText.slice(
+      prevMatch.facet.index.byteEnd,
+    )
+    const nextTextAfterUri = nextMatch.rt.unicodeText.slice(
+      nextMatch.facet.index.byteEnd,
+    )
+    if (prevTextAfterUri === nextTextAfterUri) {
+      // The text you're editing is before the link, e.g.
+      // "abc google.com" -> "abcd google.com".
+      // This is a good time to add the link.
+      suggestedUris.add(uri)
+      continue
+    }
+    if (/^\s/m.test(nextTextAfterUri)) {
+      // The link is followed by a space, e.g.
+      // "google.com" -> "google.com " or
+      // "google.com." -> "google.com ".
+      // This is a clear indicator we can linkify it.
+      suggestedUris.add(uri)
+      continue
+    }
+    if (
+      /^[)]?[.,:;!?)](\s|$)/m.test(prevTextAfterUri) &&
+      /^[)]?[.,:;!?)]\s/m.test(nextTextAfterUri)
+    ) {
+      // The link was *already* being followed by punctuation,
+      // and now it's followed both by punctuation and a space.
+      // This means you're typing after punctuation, e.g.
+      // "google.com." -> "google.com. " or
+      // "google.com.foo" -> "google.com. foo".
+      // This means you're not typing the link anymore, so we can linkify it.
+      suggestedUris.add(uri)
+      continue
+    }
   }
+  for (const uri of pastSuggestedUris) {
+    if (!nextDetectedUris.has(uri)) {
+      // If a link is no longer detected, it's eligible for suggestions next time.
+      pastSuggestedUris.delete(uri)
+    }
+  }
+
+  let suggestedUri: string | undefined
+  if (suggestedUris.size > 0) {
+    suggestedUri = Array.from(suggestedUris)[0]
+    pastSuggestedUris.add(suggestedUri)
+  }
+
+  return suggestedUri
 }
 
 // https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url
@@ -46,14 +90,3 @@ function isValidUrlAndDomain(value: string) {
     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
-}