From 046e11de31a9e6ddda32811b1efab52f9c221616 Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 16 Apr 2024 14:29:32 -0700 Subject: [PATCH] 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 --- src/view/com/composer/Composer.tsx | 33 ++------- .../com/composer/text-input/TextInput.tsx | 64 ++++++++++-------- .../com/composer/text-input/TextInput.web.tsx | 67 ++++++++++++------- .../composer/text-input/text-input-util.ts | 59 ++++++++++++++++ 4 files changed, 144 insertions(+), 79 deletions(-) create mode 100644 src/view/com/composer/text-input/text-input-util.ts diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2d5c9ee7..f8af6ce1 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([]) const [threadgate, setThreadgate] = useState([]) - const [suggestedLinks, setSuggestedLinks] = useState>(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({ ) : undefined} - {!extLink && suggestedLinks.size > 0 ? ( - - {Array.from(suggestedLinks) - .slice(0, 3) - .map(url => ( - onPressAddLinkCard(url)} - accessibilityRole="button" - accessibilityLabel={_(msg`Add link card`)} - accessibilityHint={_( - msg`Creates a card with a thumbnail. The card links to ${url}`, - )}> - - Add link card:{' '} - {toShortUrl(url)} - - - ))} - - ) : null} {canSelectImages ? ( diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 20be585c..aad1d5e0 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 { richtext: RichText placeholder: string - suggestedLinks: Set setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise - onSuggestedLinksChanged: (uris: Set) => 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({start: 0, end: 0}) 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(), @@ -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 = 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 c62d1120..1038fe5d 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 - onSuggestedLinksChanged: (uris: Set) => 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()) 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 = 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 00000000..8119e429 --- /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 +}) { + // 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 +}