Skip to content

Commit

Permalink
Automatically add a link card for URLs in the composer (#3566)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
haileyok authored Apr 16, 2024
1 parent 71c427c commit 046e11d
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 79 deletions.
33 changes: 4 additions & 29 deletions src/view/com/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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`)}
Expand All @@ -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 ? (
Expand Down
64 changes: 37 additions & 27 deletions src/view/com/composer/text-input/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, {
ComponentProps,
forwardRef,
useCallback,
useRef,
useMemo,
useRef,
useState,
ComponentProps,
} from 'react'
import {
NativeSyntheticEvent,
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -56,10 +59,9 @@ export const TextInput = forwardRef(function TextInputImpl(
{
richtext,
placeholder,
suggestedLinks,
setRichText,
onPhotoPasted,
onSuggestedLinksChanged,
onNewLink,
onError,
...props
}: TextInputProps,
Expand All @@ -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(),
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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(
Expand Down
67 changes: 44 additions & 23 deletions src/view/com/composer/text-input/TextInput.web.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}

Expand All @@ -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')
Expand Down Expand Up @@ -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],
Expand Down
Loading

0 comments on commit 046e11d

Please sign in to comment.