diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/ProfileHoverCard/index.web.tsx | 134 | ||||
-rw-r--r-- | src/lib/app-info.ts | 1 | ||||
-rw-r--r-- | src/lib/sentry.ts | 17 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 33 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 64 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 67 | ||||
-rw-r--r-- | src/view/com/composer/text-input/text-input-util.ts | 59 |
7 files changed, 227 insertions, 148 deletions
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 370baccbb..d7036e779 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -59,9 +59,9 @@ type Action = | 'pressed' | 'hovered' | 'unhovered' - | 'show-timer-elapsed' - | 'hide-timer-elapsed' - | 'hide-animation-completed' + | 'hovered-long-enough' + | 'unhovered-long-enough' + | 'finished-animating-hide' const SHOW_DELAY = 350 const SHOW_DURATION = 300 @@ -76,90 +76,110 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { const [currentState, dispatch] = React.useReducer( // Tip: console.log(state, action) when debugging. (state: State, action: Action): State => { - // Regardless of which stage we're in, pressing always hides the card. + // Pressing within a card should always hide it. + // No matter which stage we're in. if (action === 'pressed') { + return hidden() + } + + // --- Hidden --- + // In the beginning, the card is not displayed. + function hidden(): State { return {stage: 'hidden'} } + // The user can kick things off by hovering a target. if (state.stage === 'hidden') { - // Our story starts when the card is hidden. - // If the user hovers, we kick off a grace period before showing the card. if (action === 'hovered') { - return { - stage: 'might-show', - effect() { - const id = setTimeout( - () => dispatch('show-timer-elapsed'), - SHOW_DELAY, - ) - return () => { - clearTimeout(id) - } - }, - } + return mightShow(SHOW_DELAY) } } + // --- Might Show --- + // The card is not visible yet but we're considering showing it. + function mightShow(waitMs: number): State { + return { + stage: 'might-show', + effect() { + const id = setTimeout(() => dispatch('hovered-long-enough'), waitMs) + return () => { + clearTimeout(id) + } + }, + } + } + + // We'll make a decision at the end of a grace period timeout. if (state.stage === 'might-show') { - // We're in the grace period when we decide whether to show the card. - // At this point, two things can happen. Either the user unhovers, and - // we go back to hidden--or they linger enough that we'll show the card. if (action === 'unhovered') { - return {stage: 'hidden'} + return hidden() } - if (action === 'show-timer-elapsed') { - return {stage: 'showing'} + if (action === 'hovered-long-enough') { + return showing() } } + // --- Showing --- + // The card is beginning to show up and then will remain visible. + function showing(): State { + return {stage: 'showing'} + } + + // If the user moves the pointer away, we'll begin to consider hiding it. if (state.stage === 'showing') { - // We're showing the card now. - // If the user unhovers, we'll start a grace period before hiding the card. if (action === 'unhovered') { - return { - stage: 'might-hide', - effect() { - const id = setTimeout( - () => dispatch('hide-timer-elapsed'), - HIDE_DELAY, - ) - return () => clearTimeout(id) - }, - } + return mightHide(HIDE_DELAY) + } + } + + // --- Might Hide --- + // The user has moved hover away from a visible card. + function mightHide(waitMs: number): State { + return { + stage: 'might-hide', + effect() { + const id = setTimeout( + () => dispatch('unhovered-long-enough'), + waitMs, + ) + return () => clearTimeout(id) + }, } } + // We'll make a decision based on whether it received hover again in time. if (state.stage === 'might-hide') { - // We're in the grace period when we decide whether to hide the card. - // At this point, two things can happen. Either the user hovers, and - // we go back to showing it--or they linger enough that we'll start hiding the card. if (action === 'hovered') { - return {stage: 'showing'} + return showing() } - if (action === 'hide-timer-elapsed') { - return { - stage: 'hiding', - effect() { - const id = setTimeout( - () => dispatch('hide-animation-completed'), - HIDE_DURATION, - ) - return () => clearTimeout(id) - }, - } + if (action === 'unhovered-long-enough') { + return hiding(HIDE_DURATION) + } + } + + // --- Hiding --- + // The user waited enough outside that we're hiding the card. + function hiding(animationDurationMs: number): State { + return { + stage: 'hiding', + effect() { + const id = setTimeout( + () => dispatch('finished-animating-hide'), + animationDurationMs, + ) + return () => clearTimeout(id) + }, } } + // While hiding, we don't want to be interrupted by anything else. + // When the animation finishes, we loop back to the initial hidden state. if (state.stage === 'hiding') { - // We're currently playing the hiding animation. - // We'll ignore all inputs now and wait for the animation to finish. - // At that point, we'll hide the entire thing, going back to square one. - if (action === 'hide-animation-completed') { - return {stage: 'hidden'} + if (action === 'finished-animating-hide') { + return hidden() } } - // Something else happened. Keep calm and carry on. return state }, {stage: 'hidden'}, diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts index 83406bf2e..af265bfcb 100644 --- a/src/lib/app-info.ts +++ b/src/lib/app-info.ts @@ -1,5 +1,6 @@ import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application' +export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index 6b6c1832d..1180b0db6 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -5,16 +5,9 @@ import {Platform} from 'react-native' import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application' -import * as info from 'expo-updates' import {init} from 'sentry-expo' -/** - * Matches the build profile `channel` props in `eas.json` - */ -const buildChannel = (info.channel || 'development') as - | 'development' - | 'preview' - | 'production' +import {BUILD_ENV, IS_DEV, IS_TESTFLIGHT} from 'lib/app-info' /** * Examples: @@ -32,16 +25,16 @@ const release = nativeApplicationVersion ?? 'dev' * - `ios.1.57.0.3` * - `android.1.57.0.46` */ -const dist = `${Platform.OS}.${release}${ - nativeBuildVersion ? `.${nativeBuildVersion}` : '' -}` +const dist = `${Platform.OS}.${nativeBuildVersion}.${ + IS_TESTFLIGHT ? 'tf' : '' +}${IS_DEV ? 'dev' : ''}` init({ autoSessionTracking: false, dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432', debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production enableInExpoDevelopment: false, // enable this to test in dev - environment: buildChannel, + environment: BUILD_ENV ?? 'development', dist, release, }) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2d5c9ee7f..f8af6ce1b 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<string[]>([]) const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) - const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(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({ </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 ? ( diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 20be585c2..aad1d5e01 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<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 } @@ -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<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(), @@ -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<string> = 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 c62d11201..1038fe5db 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<void> - onSuggestedLinksChanged: (uris: Set<string>) => 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<string>()) 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<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], 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 000000000..8119e429c --- /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<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 +} |