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.tsx160
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx64
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx32
3 files changed, 164 insertions, 92 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0fae996ff..ecfef3ecd 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -32,6 +32,8 @@ import {s, colors, gradients} from 'lib/styles'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {cleanError} from 'lib/strings/errors'
+import {shortenLinks} from 'lib/strings/rich-text-manip'
+import {toShortUrl} from 'lib/strings/url-helpers'
 import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -41,6 +43,7 @@ import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection'
 import {GalleryModel} from 'state/models/media/gallery'
 import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
+import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
 
 type Props = ComposerOpts & {
@@ -62,11 +65,14 @@ export const ComposePost = observer(function ComposePost({
   const [processingState, setProcessingState] = useState('')
   const [error, setError] = useState('')
   const [richtext, setRichText] = useState(new RichText({text: ''}))
-  const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext])
+  const graphemeLength = useMemo(() => {
+    return shortenLinks(richtext).graphemeLength
+  }, [richtext])
   const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
     initQuote,
   )
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
+  const [labels, setLabels] = useState<string[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
   const gallery = useMemo(() => new GalleryModel(store), [store])
 
@@ -145,76 +151,59 @@ export const ComposePost = observer(function ComposePost({
     [gallery, track],
   )
 
-  const onPressPublish = useCallback(
-    async (rt: RichText) => {
-      if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) {
-        return
-      }
-      if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
-        return
-      }
+  const onPressPublish = async () => {
+    if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
+      return
+    }
+    if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
+      return
+    }
 
-      setError('')
+    setError('')
 
-      if (rt.text.trim().length === 0 && gallery.isEmpty) {
-        setError('Did you want to say anything?')
-        return
-      }
+    if (richtext.text.trim().length === 0 && gallery.isEmpty) {
+      setError('Did you want to say anything?')
+      return
+    }
 
-      setIsProcessing(true)
+    setIsProcessing(true)
 
-      let createdPost
-      try {
-        createdPost = await apilib.post(store, {
-          rawText: rt.text,
-          replyTo: replyTo?.uri,
-          images: gallery.images,
-          quote: quote,
-          extLink: extLink,
-          onStateChange: setProcessingState,
-          knownHandles: autocompleteView.knownHandles,
-          langs: store.preferences.postLanguages,
-        })
-      } catch (e: any) {
-        if (extLink) {
-          setExtLink({
-            ...extLink,
-            isLoading: true,
-            localThumb: undefined,
-          } as apilib.ExternalEmbedDraft)
-        }
-        setError(cleanError(e.message))
-        setIsProcessing(false)
-        return
-      } finally {
-        track('Create Post', {
-          imageCount: gallery.size,
-        })
-        if (replyTo && replyTo.uri) track('Post:Reply')
-      }
-      if (!replyTo) {
-        await store.me.mainFeed.addPostToTop(createdPost.uri)
+    try {
+      await apilib.post(store, {
+        rawText: richtext.text,
+        replyTo: replyTo?.uri,
+        images: gallery.images,
+        quote,
+        extLink,
+        labels,
+        onStateChange: setProcessingState,
+        knownHandles: autocompleteView.knownHandles,
+        langs: store.preferences.postLanguages,
+      })
+    } catch (e: any) {
+      if (extLink) {
+        setExtLink({
+          ...extLink,
+          isLoading: true,
+          localThumb: undefined,
+        } as apilib.ExternalEmbedDraft)
       }
-      onPost?.()
-      onClose()
-      Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
-    },
-    [
-      isProcessing,
-      setError,
-      setIsProcessing,
-      replyTo,
-      autocompleteView.knownHandles,
-      extLink,
-      onClose,
-      onPost,
-      quote,
-      setExtLink,
-      store,
-      track,
-      gallery,
-    ],
-  )
+      setError(cleanError(e.message))
+      setIsProcessing(false)
+      return
+    } finally {
+      track('Create Post', {
+        imageCount: gallery.size,
+      })
+      if (replyTo && replyTo.uri) track('Post:Reply')
+    }
+    if (!replyTo) {
+      store.me.mainFeed.onPostCreated()
+    }
+    onPost?.()
+    onClose()
+    Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
+  }
 
   const canPost = useMemo(
     () =>
@@ -229,6 +218,7 @@ export const ComposePost = observer(function ComposePost({
   const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
 
   const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
+  const hasMedia = gallery.size > 0 || Boolean(extLink)
 
   return (
     <KeyboardAvoidingView
@@ -247,6 +237,7 @@ export const ComposePost = observer(function ComposePost({
             <Text style={[pal.link, s.f18]}>Cancel</Text>
           </TouchableOpacity>
           <View style={s.flex1} />
+          <LabelsBtn labels={labels} onChange={setLabels} hasMedia={hasMedia} />
           {isProcessing ? (
             <View style={styles.postBtn}>
               <ActivityIndicator />
@@ -254,9 +245,7 @@ export const ComposePost = observer(function ComposePost({
           ) : canPost ? (
             <TouchableOpacity
               testID="composerPublishBtn"
-              onPress={() => {
-                onPressPublish(richtext)
-              }}
+              onPress={onPressPublish}
               accessibilityRole="button"
               accessibilityLabel={replyTo ? 'Publish reply' : 'Publish post'}
               accessibilityHint={
@@ -366,20 +355,23 @@ export const ComposePost = observer(function ComposePost({
         </ScrollView>
         {!extLink && 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)}
-                accessibilityRole="button"
-                accessibilityLabel="Add link card"
-                accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
-                <Text style={pal.text}>
-                  Add link card: <Text style={pal.link}>{url}</Text>
-                </Text>
-              </TouchableOpacity>
-            ))}
+            {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="Add link card"
+                  accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
+                  <Text style={pal.text}>
+                    Add link card:{' '}
+                    <Text style={pal.link}>{toShortUrl(url)}</Text>
+                  </Text>
+                </TouchableOpacity>
+              ))}
           </View>
         ) : null}
         <View style={[pal.border, styles.bottomBar]}>
@@ -408,7 +400,7 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     paddingTop: isDesktopWeb ? 10 : undefined,
-    paddingBottom: 10,
+    paddingBottom: isDesktopWeb ? 10 : 4,
     paddingHorizontal: 20,
     height: 55,
   },
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
new file mode 100644
index 000000000..96908d47f
--- /dev/null
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import {Keyboard, StyleSheet} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {Button} from 'view/com/util/forms/Button'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {ShieldExclamation} from 'lib/icons'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
+import {isNative} from 'platform/detection'
+
+export const LabelsBtn = observer(function LabelsBtn({
+  labels,
+  hasMedia,
+  onChange,
+}: {
+  labels: string[]
+  hasMedia: boolean
+  onChange: (v: string[]) => void
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+
+  return (
+    <Button
+      type="default-light"
+      testID="labelsBtn"
+      style={[styles.button, !hasMedia && styles.dimmed]}
+      accessibilityLabel="Content warnings"
+      accessibilityHint=""
+      onPress={() => {
+        if (isNative) {
+          if (Keyboard.isVisible()) {
+            Keyboard.dismiss()
+          }
+        }
+        store.shell.openModal({name: 'self-label', labels, hasMedia, onChange})
+      }}>
+      <ShieldExclamation style={pal.link} size={26} />
+      {labels.length > 0 ? (
+        <FontAwesomeIcon
+          icon="check"
+          size={16}
+          style={pal.link as FontAwesomeIconStyle}
+        />
+      ) : null}
+    </Button>
+  )
+})
+
+const styles = StyleSheet.create({
+  button: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 14,
+    marginRight: 4,
+  },
+  dimmed: {
+    opacity: 0.4,
+  },
+  label: {
+    maxWidth: 100,
+  },
+})
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 245c17b9c..f64880e15 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -1,6 +1,7 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {RichText} from '@atproto/api'
+import EventEmitter from 'eventemitter3'
 import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
 import {Document} from '@tiptap/extension-document'
 import History from '@tiptap/extension-history'
@@ -53,6 +54,22 @@ export const TextInput = React.forwardRef(
       'ProseMirror-dark',
     )
 
+    // we use a memoized emitter to propagate events out of tiptap
+    // without triggering re-runs of the useEditor hook
+    const emitter = React.useMemo(() => new EventEmitter(), [])
+    React.useEffect(() => {
+      emitter.addListener('publish', onPressPublish)
+      return () => {
+        emitter.removeListener('publish', onPressPublish)
+      }
+    }, [emitter, onPressPublish])
+    React.useEffect(() => {
+      emitter.addListener('photo-pasted', onPhotoPasted)
+      return () => {
+        emitter.removeListener('photo-pasted', onPhotoPasted)
+      }
+    }, [emitter, onPhotoPasted])
+
     const editor = useEditor(
       {
         extensions: [
@@ -60,6 +77,7 @@ export const TextInput = React.forwardRef(
           Link.configure({
             protocols: ['http', 'https'],
             autolink: true,
+            linkOnPaste: false,
           }),
           Mention.configure({
             HTMLAttributes: {
@@ -86,16 +104,13 @@ export const TextInput = React.forwardRef(
               return
             }
 
-            getImageFromUri(items, onPhotoPasted)
+            getImageFromUri(items, (uri: string) => {
+              emitter.emit('photo-pasted', uri)
+            })
           },
           handleKeyDown: (_, event) => {
             if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
-              // Workaround relying on previous state from `setRichText` to
-              // get the updated text content during editor initialization
-              setRichText((state: RichText) => {
-                onPressPublish(state)
-                return state
-              })
+              emitter.emit('publish')
             }
           },
         },
@@ -107,6 +122,7 @@ export const TextInput = React.forwardRef(
           const json = editorProp.getJSON()
 
           const newRt = new RichText({text: editorJsonToText(json).trim()})
+          newRt.detectFacetsWithoutResolution()
           setRichText(newRt)
 
           const newSuggestedLinks = new Set(editorJsonToLinks(json))
@@ -115,7 +131,7 @@ export const TextInput = React.forwardRef(
           }
         },
       },
-      [modeClass],
+      [modeClass, emitter],
     )
 
     React.useImperativeHandle(ref, () => ({