diff options
Diffstat (limited to 'src/view/com/composer')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 73 | ||||
-rw-r--r-- | src/view/com/composer/char-progress/CharProgress.tsx | 18 | ||||
-rw-r--r-- | src/view/com/composer/photos/OpenCameraBtn.tsx | 6 | ||||
-rw-r--r-- | src/view/com/composer/photos/SelectPhotoBtn.tsx | 6 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 53 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 15 |
6 files changed, 96 insertions, 75 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 572eea927..6009debdd 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react' +import React from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, @@ -13,6 +13,7 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {ExternalEmbed} from './ExternalEmbed' @@ -30,11 +31,11 @@ import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn' import {SelectedPhotos} from './photos/SelectedPhotos' import {usePalette} from 'lib/hooks/usePalette' -import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' +import QuoteEmbed from '../util/post-embeds/QuoteEmbed' import {useExternalLinkFetch} from './useExternalLinkFetch' import {isDesktopWeb} from 'platform/detection' -const MAX_TEXT_LENGTH = 256 +const MAX_GRAPHEME_LENGTH = 300 export const ComposePost = observer(function ComposePost({ replyTo, @@ -50,17 +51,23 @@ export const ComposePost = observer(function ComposePost({ const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() - const textInput = useRef<TextInputRef>(null) - const [isProcessing, setIsProcessing] = useState(false) - const [processingState, setProcessingState] = useState('') - const [error, setError] = useState('') - const [text, setText] = useState('') - const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( + const textInput = React.useRef<TextInputRef>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [processingState, setProcessingState] = React.useState('') + const [error, setError] = React.useState('') + const [richtext, setRichText] = React.useState(new RichText({text: ''})) + const graphemeLength = React.useMemo( + () => richtext.graphemeLength, + [richtext], + ) + const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>( initQuote, ) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) - const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) - const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) + const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>( + new Set(), + ) + const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([]) const autocompleteView = React.useMemo<UserAutocompleteViewModel>( () => new UserAutocompleteViewModel(store), @@ -78,11 +85,11 @@ export const ComposePost = observer(function ComposePost({ }, [textInput, onClose]) // initial setup - useEffect(() => { + React.useEffect(() => { autocompleteView.setup() }, [autocompleteView]) - useEffect(() => { + React.useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view // -prf @@ -132,18 +139,18 @@ export const ComposePost = observer(function ComposePost({ if (isProcessing) { return } - if (text.length > MAX_TEXT_LENGTH) { + if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) { return } setError('') - if (text.trim().length === 0 && selectedPhotos.length === 0) { + if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) { setError('Did you want to say anything?') return false } setIsProcessing(true) try { await apilib.post(store, { - rawText: text, + rawText: richtext.text, replyTo: replyTo?.uri, images: selectedPhotos, quote: quote, @@ -172,7 +179,7 @@ export const ComposePost = observer(function ComposePost({ Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) }, [ isProcessing, - text, + richtext, setError, setIsProcessing, replyTo, @@ -187,7 +194,7 @@ export const ComposePost = observer(function ComposePost({ track, ]) - const canPost = text.length <= MAX_TEXT_LENGTH + const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH const selectTextInputPlaceholder = replyTo ? 'Write your reply' @@ -215,7 +222,7 @@ export const ComposePost = observer(function ComposePost({ </View> ) : canPost ? ( <TouchableOpacity - testID="composerPublishButton" + testID="composerPublishBtn" onPress={onPressPublish}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} @@ -271,42 +278,41 @@ export const ComposePost = observer(function ComposePost({ <UserAvatar avatar={store.me.avatar} size={50} /> <TextInput ref={textInput} - text={text} + richtext={richtext} placeholder={selectTextInputPlaceholder} suggestedLinks={suggestedLinks} autocompleteView={autocompleteView} - onTextChanged={setText} + setRichText={setRichText} onPhotoPasted={onPhotoPasted} onSuggestedLinksChanged={setSuggestedLinks} onError={setError} /> </View> - {quote ? ( - <View style={s.mt5}> - <QuoteEmbed quote={quote} /> - </View> - ) : undefined} - <SelectedPhotos selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> - {!selectedPhotos.length && extLink && ( + {selectedPhotos.length === 0 && extLink && ( <ExternalEmbed link={extLink} onRemove={() => setExtLink(undefined)} /> )} + {quote ? ( + <View style={s.mt5}> + <QuoteEmbed quote={quote} /> + </View> + ) : undefined} </ScrollView> {!extLink && selectedPhotos.length === 0 && - suggestedLinks.size > 0 && - !quote ? ( + suggestedLinks.size > 0 ? ( <View style={s.mb5}> {Array.from(suggestedLinks).map(url => ( <TouchableOpacity key={`suggested-${url}`} + testID="addLinkCardBtn" style={[pal.borderDark, styles.addExtLinkBtn]} onPress={() => onPressAddLinkCard(url)}> <Text style={pal.text}> @@ -318,17 +324,17 @@ export const ComposePost = observer(function ComposePost({ ) : null} <View style={[pal.border, styles.bottomBar]}> <SelectPhotoBtn - enabled={!quote && selectedPhotos.length < 4} + enabled={selectedPhotos.length < 4} selectedPhotos={selectedPhotos} onSelectPhotos={setSelectedPhotos} /> <OpenCameraBtn - enabled={!quote && selectedPhotos.length < 4} + enabled={selectedPhotos.length < 4} selectedPhotos={selectedPhotos} onSelectPhotos={setSelectedPhotos} /> <View style={s.flex1} /> - <CharProgress count={text.length} /> + <CharProgress count={graphemeLength} /> </View> </SafeAreaView> </TouchableWithoutFeedback> @@ -408,6 +414,7 @@ const styles = StyleSheet.create({ borderRadius: 24, paddingHorizontal: 16, paddingVertical: 12, + marginHorizontal: 10, marginBottom: 4, }, bottomBar: { diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index b17cad1ba..eaaaea5e5 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -8,26 +8,24 @@ import ProgressPie from 'react-native-progress/Pie' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -const MAX_TEXT_LENGTH = 256 -const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH +const MAX_LENGTH = 300 +const DANGER_LENGTH = MAX_LENGTH export function CharProgress({count}: {count: number}) { const pal = usePalette('default') - const textColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.text - const circleColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.link + const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text + const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link return ( <> - <Text style={[s.mr10, {color: textColor}]}> - {MAX_TEXT_LENGTH - count} - </Text> + <Text style={[s.mr10, {color: textColor}]}>{MAX_LENGTH - count}</Text> <View> - {count > DANGER_TEXT_LENGTH ? ( + {count > DANGER_LENGTH ? ( <ProgressPie size={30} borderWidth={4} borderColor={circleColor} color={circleColor} - progress={Math.min((count - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH, 1)} + progress={Math.min((count - MAX_LENGTH) / MAX_LENGTH, 1)} /> ) : ( <ProgressCircle @@ -35,7 +33,7 @@ export function CharProgress({count}: {count: number}) { borderWidth={1} borderColor={pal.colors.border} color={circleColor} - progress={count / MAX_TEXT_LENGTH} + progress={count / MAX_LENGTH} /> )} </View> diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index cf4a4c7d1..118728781 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -76,7 +76,11 @@ export function OpenCameraBtn({ hitSlop={HITSLOP}> <FontAwesomeIcon icon="camera" - style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + style={ + (enabled + ? pal.link + : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle + } size={24} /> </TouchableOpacity> diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index bdcb0534a..888118a85 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -86,7 +86,11 @@ export function SelectPhotoBtn({ hitSlop={HITSLOP}> <FontAwesomeIcon icon={['far', 'image']} - style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + style={ + (enabled + ? pal.link + : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle + } size={24} /> </TouchableOpacity> diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index e72b41f0a..393d168fe 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -9,13 +9,13 @@ import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' +import {AppBskyRichtextFacet, RichText} from '@atproto/api' import isEqual from 'lodash.isequal' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {cleanError} from 'lib/strings/errors' -import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' import {getImageDim} from 'lib/media/manip' import {cropAndCompressFlow} from 'lib/media/picker' import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' @@ -33,11 +33,11 @@ export interface TextInputRef { } interface TextInputProps { - text: string + richtext: RichText placeholder: string suggestedLinks: Set<string> autocompleteView: UserAutocompleteViewModel - onTextChanged: (v: string) => void + setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void onSuggestedLinksChanged: (uris: Set<string>) => void onError: (err: string) => void @@ -51,11 +51,11 @@ interface Selection { export const TextInput = React.forwardRef( ( { - text, + richtext, placeholder, suggestedLinks, autocompleteView, - onTextChanged, + setRichText, onPhotoPasted, onSuggestedLinksChanged, onError, @@ -92,7 +92,9 @@ export const TextInput = React.forwardRef( const onChangeText = React.useCallback( (newText: string) => { - onTextChanged(newText) + const newRt = new RichText({text: newText}) + newRt.detectFacetsWithoutResolution() + setRichText(newRt) const prefix = getMentionAt( newText, @@ -105,20 +107,21 @@ export const TextInput = React.forwardRef( autocompleteView.setActive(false) } - const ents = extractEntities(newText)?.filter( - ent => ent.type === 'link', - ) - const set = new Set(ents ? ents.map(e => e.value) : []) + 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) + } + } + } + } if (!isEqual(set, suggestedLinks)) { onSuggestedLinksChanged(set) } }, - [ - onTextChanged, - autocompleteView, - suggestedLinks, - onSuggestedLinksChanged, - ], + [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged], ) const onPaste = React.useCallback( @@ -159,31 +162,35 @@ export const TextInput = React.forwardRef( const onSelectAutocompleteItem = React.useCallback( (item: string) => { onChangeText( - insertMentionAt(text, textInputSelection.current?.start || 0, item), + insertMentionAt( + richtext.text, + textInputSelection.current?.start || 0, + item, + ), ) autocompleteView.setActive(false) }, - [onChangeText, text, autocompleteView], + [onChangeText, richtext, autocompleteView], ) const textDecorated = React.useMemo(() => { let i = 0 - return detectLinkables(text).map(v => { - if (typeof v === 'string') { + return Array.from(richtext.segments()).map(segment => { + if (!segment.facet) { return ( <Text key={i++} style={[pal.text, styles.textInputFormatting]}> - {v} + {segment.text} </Text> ) } else { return ( <Text key={i++} style={[pal.link, styles.textInputFormatting]}> - {v.link} + {segment.text} </Text> ) } }) - }, [text, pal.link, pal.text]) + }, [richtext, pal.link, pal.text]) return ( <View style={styles.container}> diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 4b23e891b..ad891fa5b 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,5 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' +import {RichText} from '@atproto/api' import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Document} from '@tiptap/extension-document' import {Link} from '@tiptap/extension-link' @@ -17,11 +18,11 @@ export interface TextInputRef { } interface TextInputProps { - text: string + richtext: RichText placeholder: string suggestedLinks: Set<string> autocompleteView: UserAutocompleteViewModel - onTextChanged: (v: string) => void + setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void onSuggestedLinksChanged: (uris: Set<string>) => void onError: (err: string) => void @@ -30,11 +31,11 @@ interface TextInputProps { export const TextInput = React.forwardRef( ( { - text, + richtext, placeholder, suggestedLinks, autocompleteView, - onTextChanged, + setRichText, // onPhotoPasted, TODO onSuggestedLinksChanged, }: // onError, TODO @@ -60,15 +61,15 @@ export const TextInput = React.forwardRef( }), Text, ], - content: text, + content: richtext.text.toString(), autofocus: true, editable: true, injectCSS: true, onUpdate({editor: editorProp}) { const json = editorProp.getJSON() - const newText = editorJsonToText(json).trim() - onTextChanged(newText) + const newRt = new RichText({text: editorJsonToText(json).trim()}) + setRichText(newRt) const newSuggestedLinks = new Set(editorJsonToLinks(json)) if (!isEqual(newSuggestedLinks, suggestedLinks)) { |