about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app.config.js11
-rw-r--r--src/components/ProfileHoverCard/index.web.tsx134
-rw-r--r--src/lib/app-info.ts1
-rw-r--r--src/lib/sentry.ts17
-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
8 files changed, 232 insertions, 154 deletions
diff --git a/app.config.js b/app.config.js
index c42ae5a31..dbec56195 100644
--- a/app.config.js
+++ b/app.config.js
@@ -35,11 +35,6 @@ module.exports = function (config) {
    */
   const PLATFORM = process.env.EAS_BUILD_PLATFORM
 
-  const DIST_BUILD_NUMBER =
-    PLATFORM === 'android'
-      ? process.env.BSKY_ANDROID_VERSION_CODE
-      : process.env.BSKY_IOS_BUILD_NUMBER
-
   const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
   const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
   const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
@@ -51,6 +46,10 @@ module.exports = function (config) {
     : undefined
   const UPDATES_ENABLED = !!UPDATES_CHANNEL
 
+  const SENTRY_DIST = `${PLATFORM}.${VERSION}.${IS_TESTFLIGHT ? 'tf' : ''}${
+    IS_DEV ? 'dev' : ''
+  }`
+
   return {
     expo: {
       version: VERSION,
@@ -217,7 +216,7 @@ module.exports = function (config) {
               organization: 'blueskyweb',
               project: 'react-native',
               release: VERSION,
-              dist: `${PLATFORM}.${VERSION}.${DIST_BUILD_NUMBER}`,
+              dist: SENTRY_DIST,
             },
           },
         ],
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index 370baccbb..d7036e779 100644
--- a/src/components/ProfileHoverCard/index.web.tsx
+++ b/src/components/ProfileHoverCard/index.web.tsx
@@ -59,9 +59,9 @@ type Action =
   | 'pressed'
   | 'hovered'
   | 'unhovered'
-  | 'show-timer-elapsed'
-  | 'hide-timer-elapsed'
-  | 'hide-animation-completed'
+  | 'hovered-long-enough'
+  | 'unhovered-long-enough'
+  | 'finished-animating-hide'
 
 const SHOW_DELAY = 350
 const SHOW_DURATION = 300
@@ -76,90 +76,110 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
   const [currentState, dispatch] = React.useReducer(
     // Tip: console.log(state, action) when debugging.
     (state: State, action: Action): State => {
-      // Regardless of which stage we're in, pressing always hides the card.
+      // Pressing within a card should always hide it.
+      // No matter which stage we're in.
       if (action === 'pressed') {
+        return hidden()
+      }
+
+      // --- Hidden ---
+      // In the beginning, the card is not displayed.
+      function hidden(): State {
         return {stage: 'hidden'}
       }
 
+      // The user can kick things off by hovering a target.
       if (state.stage === 'hidden') {
-        // Our story starts when the card is hidden.
-        // If the user hovers, we kick off a grace period before showing the card.
         if (action === 'hovered') {
-          return {
-            stage: 'might-show',
-            effect() {
-              const id = setTimeout(
-                () => dispatch('show-timer-elapsed'),
-                SHOW_DELAY,
-              )
-              return () => {
-                clearTimeout(id)
-              }
-            },
-          }
+          return mightShow(SHOW_DELAY)
         }
       }
 
+      // --- Might Show ---
+      // The card is not visible yet but we're considering showing it.
+      function mightShow(waitMs: number): State {
+        return {
+          stage: 'might-show',
+          effect() {
+            const id = setTimeout(() => dispatch('hovered-long-enough'), waitMs)
+            return () => {
+              clearTimeout(id)
+            }
+          },
+        }
+      }
+
+      // We'll make a decision at the end of a grace period timeout.
       if (state.stage === 'might-show') {
-        // We're in the grace period when we decide whether to show the card.
-        // At this point, two things can happen. Either the user unhovers, and
-        // we go back to hidden--or they linger enough that we'll show the card.
         if (action === 'unhovered') {
-          return {stage: 'hidden'}
+          return hidden()
         }
-        if (action === 'show-timer-elapsed') {
-          return {stage: 'showing'}
+        if (action === 'hovered-long-enough') {
+          return showing()
         }
       }
 
+      // --- Showing ---
+      // The card is beginning to show up and then will remain visible.
+      function showing(): State {
+        return {stage: 'showing'}
+      }
+
+      // If the user moves the pointer away, we'll begin to consider hiding it.
       if (state.stage === 'showing') {
-        // We're showing the card now.
-        // If the user unhovers, we'll start a grace period before hiding the card.
         if (action === 'unhovered') {
-          return {
-            stage: 'might-hide',
-            effect() {
-              const id = setTimeout(
-                () => dispatch('hide-timer-elapsed'),
-                HIDE_DELAY,
-              )
-              return () => clearTimeout(id)
-            },
-          }
+          return mightHide(HIDE_DELAY)
+        }
+      }
+
+      // --- Might Hide ---
+      // The user has moved hover away from a visible card.
+      function mightHide(waitMs: number): State {
+        return {
+          stage: 'might-hide',
+          effect() {
+            const id = setTimeout(
+              () => dispatch('unhovered-long-enough'),
+              waitMs,
+            )
+            return () => clearTimeout(id)
+          },
         }
       }
 
+      // We'll make a decision based on whether it received hover again in time.
       if (state.stage === 'might-hide') {
-        // We're in the grace period when we decide whether to hide the card.
-        // At this point, two things can happen. Either the user hovers, and
-        // we go back to showing it--or they linger enough that we'll start hiding the card.
         if (action === 'hovered') {
-          return {stage: 'showing'}
+          return showing()
         }
-        if (action === 'hide-timer-elapsed') {
-          return {
-            stage: 'hiding',
-            effect() {
-              const id = setTimeout(
-                () => dispatch('hide-animation-completed'),
-                HIDE_DURATION,
-              )
-              return () => clearTimeout(id)
-            },
-          }
+        if (action === 'unhovered-long-enough') {
+          return hiding(HIDE_DURATION)
+        }
+      }
+
+      // --- Hiding ---
+      // The user waited enough outside that we're hiding the card.
+      function hiding(animationDurationMs: number): State {
+        return {
+          stage: 'hiding',
+          effect() {
+            const id = setTimeout(
+              () => dispatch('finished-animating-hide'),
+              animationDurationMs,
+            )
+            return () => clearTimeout(id)
+          },
         }
       }
 
+      // While hiding, we don't want to be interrupted by anything else.
+      // When the animation finishes, we loop back to the initial hidden state.
       if (state.stage === 'hiding') {
-        // We're currently playing the hiding animation.
-        // We'll ignore all inputs now and wait for the animation to finish.
-        // At that point, we'll hide the entire thing, going back to square one.
-        if (action === 'hide-animation-completed') {
-          return {stage: 'hidden'}
+        if (action === 'finished-animating-hide') {
+          return hidden()
         }
       }
 
-      // Something else happened. Keep calm and carry on.
       return state
     },
     {stage: 'hidden'},
diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts
index 83406bf2e..af265bfcb 100644
--- a/src/lib/app-info.ts
+++ b/src/lib/app-info.ts
@@ -1,5 +1,6 @@
 import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
 
+export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
 export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
 export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
 
diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts
index 6b6c1832d..1180b0db6 100644
--- a/src/lib/sentry.ts
+++ b/src/lib/sentry.ts
@@ -5,16 +5,9 @@
 
 import {Platform} from 'react-native'
 import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
-import * as info from 'expo-updates'
 import {init} from 'sentry-expo'
 
-/**
- * Matches the build profile `channel` props in `eas.json`
- */
-const buildChannel = (info.channel || 'development') as
-  | 'development'
-  | 'preview'
-  | 'production'
+import {BUILD_ENV, IS_DEV, IS_TESTFLIGHT} from 'lib/app-info'
 
 /**
  * Examples:
@@ -32,16 +25,16 @@ const release = nativeApplicationVersion ?? 'dev'
  * - `ios.1.57.0.3`
  * - `android.1.57.0.46`
  */
-const dist = `${Platform.OS}.${release}${
-  nativeBuildVersion ? `.${nativeBuildVersion}` : ''
-}`
+const dist = `${Platform.OS}.${nativeBuildVersion}.${
+  IS_TESTFLIGHT ? 'tf' : ''
+}${IS_DEV ? 'dev' : ''}`
 
 init({
   autoSessionTracking: false,
   dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
   debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
   enableInExpoDevelopment: false, // enable this to test in dev
-  environment: buildChannel,
+  environment: BUILD_ENV ?? 'development',
   dist,
   release,
 })
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
+}