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
zio/stable
Hailey 2024-04-16 14:29:32 -07:00 committed by GitHub
parent 71c427cea8
commit 046e11de31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 79 deletions

View File

@ -42,7 +42,6 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {insertMentionAt} from 'lib/strings/mention-manip' import {insertMentionAt} from 'lib/strings/mention-manip'
import {shortenLinks} from 'lib/strings/rich-text-manip' import {shortenLinks} from 'lib/strings/rich-text-manip'
import {toShortUrl} from 'lib/strings/url-helpers'
import {colors, gradients, s} from 'lib/styles' import {colors, gradients, s} from 'lib/styles'
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
import {useDialogStateControlContext} from 'state/dialogs' import {useDialogStateControlContext} from 'state/dialogs'
@ -119,7 +118,6 @@ export const ComposePost = observer(function ComposePost({
const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [labels, setLabels] = useState<string[]>([]) const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo( const gallery = useMemo(
() => new GalleryModel(initImageUris), () => new GalleryModel(initImageUris),
[initImageUris], [initImageUris],
@ -189,11 +187,12 @@ export const ComposePost = observer(function ComposePost({
} }
}, [onEscape, isModalActive]) }, [onEscape, isModalActive])
const onPressAddLinkCard = useCallback( const onNewLink = useCallback(
(uri: string) => { (uri: string) => {
if (extLink != null) return
setExtLink({uri, isLoading: true}) setExtLink({uri, isLoading: true})
}, },
[setExtLink], [extLink, setExtLink],
) )
const onPhotoPasted = useCallback( const onPhotoPasted = useCallback(
@ -430,12 +429,11 @@ export const ComposePost = observer(function ComposePost({
ref={textInput} ref={textInput}
richtext={richtext} richtext={richtext}
placeholder={selectTextInputPlaceholder} placeholder={selectTextInputPlaceholder}
suggestedLinks={suggestedLinks}
autoFocus={true} autoFocus={true}
setRichText={setRichText} setRichText={setRichText}
onPhotoPasted={onPhotoPasted} onPhotoPasted={onPhotoPasted}
onPressPublish={onPressPublish} onPressPublish={onPressPublish}
onSuggestedLinksChanged={setSuggestedLinks} onNewLink={onNewLink}
onError={setError} onError={setError}
accessible={true} accessible={true}
accessibilityLabel={_(msg`Write post`)} accessibilityLabel={_(msg`Write post`)}
@ -458,29 +456,6 @@ export const ComposePost = observer(function ComposePost({
</View> </View>
) : undefined} ) : undefined}
</ScrollView> </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} /> <SuggestedLanguage text={richtext.text} />
<View style={[pal.border, styles.bottomBar]}> <View style={[pal.border, styles.bottomBar]}>
{canSelectImages ? ( {canSelectImages ? (

View File

@ -1,10 +1,10 @@
import React, { import React, {
ComponentProps,
forwardRef, forwardRef,
useCallback, useCallback,
useRef,
useMemo, useMemo,
useRef,
useState, useState,
ComponentProps,
} from 'react' } from 'react'
import { import {
NativeSyntheticEvent, NativeSyntheticEvent,
@ -13,22 +13,26 @@ import {
TextInputSelectionChangeEventData, TextInputSelectionChangeEventData,
View, View,
} from 'react-native' } from 'react-native'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import PasteInput, { import PasteInput, {
PastedFile, PastedFile,
PasteInputRef, PasteInputRef,
} from '@mattermost/react-native-paste-input' } from '@mattermost/react-native-paste-input'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import isEqual from 'lodash.isequal' import {POST_IMG_MAX} from 'lib/constants'
import {Autocomplete} from './mobile/Autocomplete' import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text' import {downloadAndResize} from 'lib/media/manip'
import {isUriImage} from 'lib/media/util'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext' 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 {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 { export interface TextInputRef {
focus: () => void focus: () => void
@ -39,11 +43,10 @@ export interface TextInputRef {
interface TextInputProps extends ComponentProps<typeof RNTextInput> { interface TextInputProps extends ComponentProps<typeof RNTextInput> {
richtext: RichText richtext: RichText
placeholder: string placeholder: string
suggestedLinks: Set<string>
setRichText: (v: RichText | ((v: RichText) => RichText)) => void setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<void> onPressPublish: (richtext: RichText) => Promise<void>
onSuggestedLinksChanged: (uris: Set<string>) => void onNewLink: (uri: string) => void
onError: (err: string) => void onError: (err: string) => void
} }
@ -56,10 +59,9 @@ export const TextInput = forwardRef(function TextInputImpl(
{ {
richtext, richtext,
placeholder, placeholder,
suggestedLinks,
setRichText, setRichText,
onPhotoPasted, onPhotoPasted,
onSuggestedLinksChanged, onNewLink,
onError, onError,
...props ...props
}: TextInputProps, }: TextInputProps,
@ -70,6 +72,8 @@ export const TextInput = forwardRef(function TextInputImpl(
const textInputSelection = useRef<Selection>({start: 0, end: 0}) const textInputSelection = useRef<Selection>({start: 0, end: 0})
const theme = useTheme() const theme = useTheme()
const [autocompletePrefix, setAutocompletePrefix] = useState('') const [autocompletePrefix, setAutocompletePrefix] = useState('')
const prevLength = React.useRef(richtext.length)
const prevAddedLinks = useRef(new Set<string>())
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(), focus: () => textInput.current?.focus(),
@ -92,6 +96,8 @@ export const TextInput = forwardRef(function TextInputImpl(
* @see https://github.com/bluesky-social/social-app/issues/929 * @see https://github.com/bluesky-social/social-app/issues/929
*/ */
setTimeout(async () => { setTimeout(async () => {
const mayBePaste = newText.length > prevLength.current + 1
const newRt = new RichText({text: newText}) const newRt = new RichText({text: newText})
newRt.detectFacetsWithoutResolution() newRt.detectFacetsWithoutResolution()
setRichText(newRt) setRichText(newRt)
@ -106,8 +112,6 @@ export const TextInput = forwardRef(function TextInputImpl(
setAutocompletePrefix('') setAutocompletePrefix('')
} }
const set: Set<string> = new Set()
if (newRt.facets) { if (newRt.facets) {
for (const facet of newRt.facets) { for (const facet of newRt.facets) {
for (const feature of facet.features) { for (const feature of facet.features) {
@ -126,26 +130,32 @@ export const TextInput = forwardRef(function TextInputImpl(
onPhotoPasted(res.path) onPhotoPasted(res.path)
} }
} else { } 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)) { for (const uri of prevAddedLinks.current.keys()) {
onSuggestedLinksChanged(set) if (findIndexInText(uri, newText) === -1) {
prevAddedLinks.current.delete(uri)
}
} }
prevLength.current = newText.length
}, 1) }, 1)
}, },
[ [setRichText, autocompletePrefix, onPhotoPasted, prevAddedLinks, onNewLink],
setRichText,
autocompletePrefix,
setAutocompletePrefix,
suggestedLinks,
onSuggestedLinksChanged,
onPhotoPasted,
],
) )
const onPaste = useCallback( const onPaste = useCallback(

View File

@ -1,28 +1,32 @@
import React from 'react' import React, {useRef} from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {RichText, AppBskyRichtextFacet} from '@atproto/api' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import EventEmitter from 'eventemitter3' import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Trans} from '@lingui/macro'
import {Document} from '@tiptap/extension-document' import {Document} from '@tiptap/extension-document'
import History from '@tiptap/extension-history'
import Hardbreak from '@tiptap/extension-hard-break' import Hardbreak from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history'
import {Mention} from '@tiptap/extension-mention' import {Mention} from '@tiptap/extension-mention'
import {Paragraph} from '@tiptap/extension-paragraph' import {Paragraph} from '@tiptap/extension-paragraph'
import {Placeholder} from '@tiptap/extension-placeholder' import {Placeholder} from '@tiptap/extension-placeholder'
import {Text as TiptapText} from '@tiptap/extension-text' 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 {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 {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 {Portal} from '#/components/Portal'
import {Text} from '../../util/text/Text' import {Text} from '../../util/text/Text'
import {Trans} from '@lingui/macro' import {createSuggestion} from './web/Autocomplete'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {Emoji} from './web/EmojiPicker.web'
import {LinkDecorator} from './web/LinkDecorator'
import {TagDecorator} from './web/TagDecorator' import {TagDecorator} from './web/TagDecorator'
export interface TextInputRef { export interface TextInputRef {
@ -38,7 +42,7 @@ interface TextInputProps {
setRichText: (v: RichText | ((v: RichText) => RichText)) => void setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<void> onPressPublish: (richtext: RichText) => Promise<void>
onSuggestedLinksChanged: (uris: Set<string>) => void onNewLink: (uri: string) => void
onError: (err: string) => void onError: (err: string) => void
} }
@ -48,16 +52,17 @@ export const TextInput = React.forwardRef(function TextInputImpl(
{ {
richtext, richtext,
placeholder, placeholder,
suggestedLinks,
setRichText, setRichText,
onPhotoPasted, onPhotoPasted,
onPressPublish, onPressPublish,
onSuggestedLinksChanged, onNewLink,
}: // onError, TODO }: // onError, TODO
TextInputProps, TextInputProps,
ref, ref,
) { ) {
const autocomplete = useActorAutocompleteFn() const autocomplete = useActorAutocompleteFn()
const prevLength = React.useRef(0)
const prevAddedLinks = useRef(new Set<string>())
const pal = usePalette('default') const pal = usePalette('default')
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
@ -180,26 +185,42 @@ export const TextInput = React.forwardRef(function TextInputImpl(
}, },
onUpdate({editor: editorProp}) { onUpdate({editor: editorProp}) {
const json = editorProp.getJSON() 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() newRt.detectFacetsWithoutResolution()
setRichText(newRt) setRichText(newRt)
const set: Set<string> = new Set()
if (newRt.facets) { if (newRt.facets) {
for (const facet of newRt.facets) { for (const facet of newRt.facets) {
for (const feature of facet.features) { for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) { 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)) { for (const uri of prevAddedLinks.current.keys()) {
onSuggestedLinksChanged(set) if (findIndexInText(uri, newText) === -1) {
prevAddedLinks.current.delete(uri)
}
} }
prevLength.current = newText.length
}, },
}, },
[modeClass], [modeClass],

View File

@ -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
}