Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Hailey and committed by
GitHub
046e11de 71c427ce

+144 -79
+4 -29
src/view/com/composer/Composer.tsx
··· 42 42 import {cleanError} from 'lib/strings/errors' 43 43 import {insertMentionAt} from 'lib/strings/mention-manip' 44 44 import {shortenLinks} from 'lib/strings/rich-text-manip' 45 - import {toShortUrl} from 'lib/strings/url-helpers' 46 45 import {colors, gradients, s} from 'lib/styles' 47 46 import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' 48 47 import {useDialogStateControlContext} from 'state/dialogs' ··· 119 118 const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) 120 119 const [labels, setLabels] = useState<string[]>([]) 121 120 const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) 122 - const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) 123 121 const gallery = useMemo( 124 122 () => new GalleryModel(initImageUris), 125 123 [initImageUris], ··· 189 187 } 190 188 }, [onEscape, isModalActive]) 191 189 192 - const onPressAddLinkCard = useCallback( 190 + const onNewLink = useCallback( 193 191 (uri: string) => { 192 + if (extLink != null) return 194 193 setExtLink({uri, isLoading: true}) 195 194 }, 196 - [setExtLink], 195 + [extLink, setExtLink], 197 196 ) 198 197 199 198 const onPhotoPasted = useCallback( ··· 430 429 ref={textInput} 431 430 richtext={richtext} 432 431 placeholder={selectTextInputPlaceholder} 433 - suggestedLinks={suggestedLinks} 434 432 autoFocus={true} 435 433 setRichText={setRichText} 436 434 onPhotoPasted={onPhotoPasted} 437 435 onPressPublish={onPressPublish} 438 - onSuggestedLinksChanged={setSuggestedLinks} 436 + onNewLink={onNewLink} 439 437 onError={setError} 440 438 accessible={true} 441 439 accessibilityLabel={_(msg`Write post`)} ··· 458 456 </View> 459 457 ) : undefined} 460 458 </ScrollView> 461 - {!extLink && suggestedLinks.size > 0 ? ( 462 - <View style={s.mb5}> 463 - {Array.from(suggestedLinks) 464 - .slice(0, 3) 465 - .map(url => ( 466 - <TouchableOpacity 467 - key={`suggested-${url}`} 468 - testID="addLinkCardBtn" 469 - style={[pal.borderDark, styles.addExtLinkBtn]} 470 - onPress={() => onPressAddLinkCard(url)} 471 - accessibilityRole="button" 472 - accessibilityLabel={_(msg`Add link card`)} 473 - accessibilityHint={_( 474 - msg`Creates a card with a thumbnail. The card links to ${url}`, 475 - )}> 476 - <Text style={pal.text}> 477 - <Trans>Add link card:</Trans>{' '} 478 - <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text> 479 - </Text> 480 - </TouchableOpacity> 481 - ))} 482 - </View> 483 - ) : null} 484 459 <SuggestedLanguage text={richtext.text} /> 485 460 <View style={[pal.border, styles.bottomBar]}> 486 461 {canSelectImages ? (
+37 -27
src/view/com/composer/text-input/TextInput.tsx
··· 1 1 import React, { 2 + ComponentProps, 2 3 forwardRef, 3 4 useCallback, 4 - useRef, 5 5 useMemo, 6 + useRef, 6 7 useState, 7 - ComponentProps, 8 8 } from 'react' 9 9 import { 10 10 NativeSyntheticEvent, ··· 13 13 TextInputSelectionChangeEventData, 14 14 View, 15 15 } from 'react-native' 16 + import {AppBskyRichtextFacet, RichText} from '@atproto/api' 16 17 import PasteInput, { 17 18 PastedFile, 18 19 PasteInputRef, 19 20 } from '@mattermost/react-native-paste-input' 20 - import {AppBskyRichtextFacet, RichText} from '@atproto/api' 21 - import isEqual from 'lodash.isequal' 22 - import {Autocomplete} from './mobile/Autocomplete' 23 - import {Text} from 'view/com/util/text/Text' 21 + 22 + import {POST_IMG_MAX} from 'lib/constants' 23 + import {usePalette} from 'lib/hooks/usePalette' 24 + import {downloadAndResize} from 'lib/media/manip' 25 + import {isUriImage} from 'lib/media/util' 24 26 import {cleanError} from 'lib/strings/errors' 25 27 import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' 26 - import {usePalette} from 'lib/hooks/usePalette' 27 28 import {useTheme} from 'lib/ThemeContext' 28 - import {isUriImage} from 'lib/media/util' 29 - import {downloadAndResize} from 'lib/media/manip' 30 - import {POST_IMG_MAX} from 'lib/constants' 31 29 import {isIOS} from 'platform/detection' 30 + import { 31 + addLinkCardIfNecessary, 32 + findIndexInText, 33 + } from 'view/com/composer/text-input/text-input-util' 34 + import {Text} from 'view/com/util/text/Text' 35 + import {Autocomplete} from './mobile/Autocomplete' 32 36 33 37 export interface TextInputRef { 34 38 focus: () => void ··· 39 43 interface TextInputProps extends ComponentProps<typeof RNTextInput> { 40 44 richtext: RichText 41 45 placeholder: string 42 - suggestedLinks: Set<string> 43 46 setRichText: (v: RichText | ((v: RichText) => RichText)) => void 44 47 onPhotoPasted: (uri: string) => void 45 48 onPressPublish: (richtext: RichText) => Promise<void> 46 - onSuggestedLinksChanged: (uris: Set<string>) => void 49 + onNewLink: (uri: string) => void 47 50 onError: (err: string) => void 48 51 } 49 52 ··· 56 59 { 57 60 richtext, 58 61 placeholder, 59 - suggestedLinks, 60 62 setRichText, 61 63 onPhotoPasted, 62 - onSuggestedLinksChanged, 64 + onNewLink, 63 65 onError, 64 66 ...props 65 67 }: TextInputProps, ··· 70 72 const textInputSelection = useRef<Selection>({start: 0, end: 0}) 71 73 const theme = useTheme() 72 74 const [autocompletePrefix, setAutocompletePrefix] = useState('') 75 + const prevLength = React.useRef(richtext.length) 76 + const prevAddedLinks = useRef(new Set<string>()) 73 77 74 78 React.useImperativeHandle(ref, () => ({ 75 79 focus: () => textInput.current?.focus(), ··· 92 96 * @see https://github.com/bluesky-social/social-app/issues/929 93 97 */ 94 98 setTimeout(async () => { 99 + const mayBePaste = newText.length > prevLength.current + 1 100 + 95 101 const newRt = new RichText({text: newText}) 96 102 newRt.detectFacetsWithoutResolution() 97 103 setRichText(newRt) ··· 105 111 } else if (autocompletePrefix) { 106 112 setAutocompletePrefix('') 107 113 } 108 - 109 - const set: Set<string> = new Set() 110 114 111 115 if (newRt.facets) { 112 116 for (const facet of newRt.facets) { ··· 126 130 onPhotoPasted(res.path) 127 131 } 128 132 } else { 129 - set.add(feature.uri) 133 + const cursorLocation = textInputSelection.current.end 134 + 135 + addLinkCardIfNecessary({ 136 + uri: feature.uri, 137 + newText, 138 + cursorLocation, 139 + mayBePaste, 140 + onNewLink, 141 + prevAddedLinks: prevAddedLinks.current, 142 + }) 130 143 } 131 144 } 132 145 } 133 146 } 134 147 } 135 148 136 - if (!isEqual(set, suggestedLinks)) { 137 - onSuggestedLinksChanged(set) 149 + for (const uri of prevAddedLinks.current.keys()) { 150 + if (findIndexInText(uri, newText) === -1) { 151 + prevAddedLinks.current.delete(uri) 152 + } 138 153 } 154 + 155 + prevLength.current = newText.length 139 156 }, 1) 140 157 }, 141 - [ 142 - setRichText, 143 - autocompletePrefix, 144 - setAutocompletePrefix, 145 - suggestedLinks, 146 - onSuggestedLinksChanged, 147 - onPhotoPasted, 148 - ], 158 + [setRichText, autocompletePrefix, onPhotoPasted, prevAddedLinks, onNewLink], 149 159 ) 150 160 151 161 const onPaste = useCallback(
+44 -23
src/view/com/composer/text-input/TextInput.web.tsx
··· 1 - import React from 'react' 1 + import React, {useRef} from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 - import {RichText, AppBskyRichtextFacet} from '@atproto/api' 4 - import EventEmitter from 'eventemitter3' 5 - import {useEditor, EditorContent, JSONContent} from '@tiptap/react' 3 + import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 4 + import {AppBskyRichtextFacet, RichText} from '@atproto/api' 5 + import {Trans} from '@lingui/macro' 6 6 import {Document} from '@tiptap/extension-document' 7 - import History from '@tiptap/extension-history' 8 7 import Hardbreak from '@tiptap/extension-hard-break' 8 + import History from '@tiptap/extension-history' 9 9 import {Mention} from '@tiptap/extension-mention' 10 10 import {Paragraph} from '@tiptap/extension-paragraph' 11 11 import {Placeholder} from '@tiptap/extension-placeholder' 12 12 import {Text as TiptapText} from '@tiptap/extension-text' 13 - import isEqual from 'lodash.isequal' 14 - import {createSuggestion} from './web/Autocomplete' 15 - import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 16 - import {isUriImage, blobToDataUri} from 'lib/media/util' 17 - import {Emoji} from './web/EmojiPicker.web' 18 - import {LinkDecorator} from './web/LinkDecorator' 19 13 import {generateJSON} from '@tiptap/html' 14 + import {EditorContent, JSONContent, useEditor} from '@tiptap/react' 15 + import EventEmitter from 'eventemitter3' 16 + 17 + import {usePalette} from '#/lib/hooks/usePalette' 20 18 import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 21 - import {usePalette} from '#/lib/hooks/usePalette' 19 + import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 20 + import {blobToDataUri, isUriImage} from 'lib/media/util' 21 + import { 22 + addLinkCardIfNecessary, 23 + findIndexInText, 24 + } from 'view/com/composer/text-input/text-input-util' 22 25 import {Portal} from '#/components/Portal' 23 26 import {Text} from '../../util/text/Text' 24 - import {Trans} from '@lingui/macro' 25 - import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 27 + import {createSuggestion} from './web/Autocomplete' 28 + import {Emoji} from './web/EmojiPicker.web' 29 + import {LinkDecorator} from './web/LinkDecorator' 26 30 import {TagDecorator} from './web/TagDecorator' 27 31 28 32 export interface TextInputRef { ··· 38 42 setRichText: (v: RichText | ((v: RichText) => RichText)) => void 39 43 onPhotoPasted: (uri: string) => void 40 44 onPressPublish: (richtext: RichText) => Promise<void> 41 - onSuggestedLinksChanged: (uris: Set<string>) => void 45 + onNewLink: (uri: string) => void 42 46 onError: (err: string) => void 43 47 } 44 48 ··· 48 52 { 49 53 richtext, 50 54 placeholder, 51 - suggestedLinks, 52 55 setRichText, 53 56 onPhotoPasted, 54 57 onPressPublish, 55 - onSuggestedLinksChanged, 58 + onNewLink, 56 59 }: // onError, TODO 57 60 TextInputProps, 58 61 ref, 59 62 ) { 60 63 const autocomplete = useActorAutocompleteFn() 64 + const prevLength = React.useRef(0) 65 + const prevAddedLinks = useRef(new Set<string>()) 61 66 62 67 const pal = usePalette('default') 63 68 const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') ··· 180 185 }, 181 186 onUpdate({editor: editorProp}) { 182 187 const json = editorProp.getJSON() 188 + const newText = editorJsonToText(json).trimEnd() 189 + const mayBePaste = newText.length > prevLength.current + 1 183 190 184 - const newRt = new RichText({text: editorJsonToText(json).trimEnd()}) 191 + const newRt = new RichText({text: newText}) 185 192 newRt.detectFacetsWithoutResolution() 186 193 setRichText(newRt) 187 194 188 - const set: Set<string> = new Set() 189 - 190 195 if (newRt.facets) { 191 196 for (const facet of newRt.facets) { 192 197 for (const feature of facet.features) { 193 198 if (AppBskyRichtextFacet.isLink(feature)) { 194 - set.add(feature.uri) 199 + // The TipTap editor shows the position as being one character ahead, as if the start index is 1. 200 + // Subtracting 1 from the pos gives us the same behavior as the native impl. 201 + let cursorLocation = editor?.state.selection.$anchor.pos ?? 1 202 + cursorLocation -= 1 203 + 204 + addLinkCardIfNecessary({ 205 + uri: feature.uri, 206 + newText, 207 + cursorLocation, 208 + mayBePaste, 209 + onNewLink, 210 + prevAddedLinks: prevAddedLinks.current, 211 + }) 195 212 } 196 213 } 197 214 } 198 215 } 199 216 200 - if (!isEqual(set, suggestedLinks)) { 201 - onSuggestedLinksChanged(set) 217 + for (const uri of prevAddedLinks.current.keys()) { 218 + if (findIndexInText(uri, newText) === -1) { 219 + prevAddedLinks.current.delete(uri) 220 + } 202 221 } 222 + 223 + prevLength.current = newText.length 203 224 }, 204 225 }, 205 226 [modeClass],
+59
src/view/com/composer/text-input/text-input-util.ts
··· 1 + export function addLinkCardIfNecessary({ 2 + uri, 3 + newText, 4 + cursorLocation, 5 + mayBePaste, 6 + onNewLink, 7 + prevAddedLinks, 8 + }: { 9 + uri: string 10 + newText: string 11 + cursorLocation: number 12 + mayBePaste: boolean 13 + onNewLink: (uri: string) => void 14 + prevAddedLinks: Set<string> 15 + }) { 16 + // It would be cool if we could just use facet.index.byteEnd, but you know... *upside down smiley* 17 + const lastCharacterPosition = findIndexInText(uri, newText) + uri.length 18 + 19 + // If the text being added is not from a paste, then we should only check if the cursor is one 20 + // position ahead of the last character. However, if it is a paste we need to check both if it's 21 + // the same position _or_ one position ahead. That is because iOS will add a space after a paste if 22 + // pasting into the middle of a sentence! 23 + const cursorLocationIsOkay = 24 + cursorLocation === lastCharacterPosition + 1 || mayBePaste 25 + 26 + // Checking previouslyAddedLinks keeps a card from getting added over and over i.e. 27 + // Link card added -> Remove link card -> Press back space -> Press space -> Link card added -> and so on 28 + 29 + // We use the isValidUrl regex below because we don't want to add embeds only if the url is valid, i.e. 30 + // http://facebook is a valid url, but that doesn't mean we want to embed it. We should only embed if 31 + // the url is a valid url _and_ domain. new URL() won't work for this check. 32 + const shouldCheck = 33 + cursorLocationIsOkay && !prevAddedLinks.has(uri) && isValidUrlAndDomain(uri) 34 + 35 + if (shouldCheck) { 36 + onNewLink(uri) 37 + prevAddedLinks.add(uri) 38 + } 39 + } 40 + 41 + // https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url 42 + // question credit Muhammad Imran Tariq https://stackoverflow.com/users/420613/muhammad-imran-tariq 43 + // answer credit Christian David https://stackoverflow.com/users/967956/christian-david 44 + function isValidUrlAndDomain(value: string) { 45 + 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( 46 + value, 47 + ) 48 + } 49 + 50 + export function findIndexInText(term: string, text: string) { 51 + // This should find patterns like: 52 + // HELLO SENTENCE http://google.com/ HELLO 53 + // HELLO SENTENCE http://google.com HELLO 54 + // http://google.com/ HELLO. 55 + // http://google.com/. 56 + const pattern = new RegExp(`\\b(${term})(?![/w])`, 'i') 57 + const match = pattern.exec(text) 58 + return match ? match.index : -1 59 + }