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()) 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()) + const prevDetectedUris = useRef(new Map()) const onChangeText = useCallback( (newText: string) => { /* @@ -112,6 +113,7 @@ export const TextInput = forwardRef(function TextInputImpl( setAutocompletePrefix('') } + const nextDetectedUris = new Map() 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()) - 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()) + const prevDetectedUris = useRef(new Map()) 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() 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 -}) { - // 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, + prevDetectedUris: Map, + pastSuggestedUris: Set, +): string | undefined { + const suggestedUris = new Set() + 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 -}