about summary refs log tree commit diff
path: root/src/view/com/composer
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/composer')
-rw-r--r--src/view/com/composer/Composer.tsx73
-rw-r--r--src/view/com/composer/char-progress/CharProgress.tsx18
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx6
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx6
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx53
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx15
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)) {