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/ComposerReplyTo.tsx7
-rw-r--r--src/view/com/composer/Prompt.tsx6
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx19
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx8
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx3
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx2
-rw-r--r--src/view/com/composer/text-input/mobile/Autocomplete.tsx6
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx6
-rw-r--r--src/view/com/composer/text-input/web/LinkDecorator.ts5
-rw-r--r--src/view/com/composer/text-input/web/TagDecorator.ts91
11 files changed, 189 insertions, 37 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 1ed6b98a5..ab7551b60 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -39,7 +39,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useExternalLinkFetch} from './useExternalLinkFetch'
 import {isWeb, isNative, isAndroid, isIOS} from 'platform/detection'
-import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
+import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
 import {GalleryModel} from 'state/models/media/gallery'
 import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
@@ -49,7 +49,7 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
 import {insertMentionAt} from 'lib/strings/mention-manip'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useModals, useModalControls} from '#/state/modals'
+import {useModals} from '#/state/modals'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
   useLanguagePrefs,
@@ -63,6 +63,9 @@ import {emitPostCreated} from '#/state/events'
 import {ThreadgateSetting} from '#/state/queries/threadgate'
 import {logger} from '#/logger'
 import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
+import * as Prompt from '#/components/Prompt'
+import {useDialogStateControlContext} from 'state/dialogs'
+import {logEvent} from '#/lib/statsig/statsig'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -71,11 +74,12 @@ export const ComposePost = observer(function ComposePost({
   quote: initQuote,
   mention: initMention,
   openPicker,
+  text: initText,
+  imageUris: initImageUris,
 }: Props) {
   const {currentAccount} = useSession()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
-  const {isModalActive, activeModals} = useModals()
-  const {openModal, closeModal} = useModalControls()
+  const {isModalActive} = useModals()
   const {closeComposer} = useComposerControls()
   const {track} = useAnalytics()
   const pal = usePalette('default')
@@ -85,13 +89,18 @@ export const ComposePost = observer(function ComposePost({
   const langPrefs = useLanguagePrefs()
   const setLangPrefs = useLanguagePrefsApi()
   const textInput = useRef<TextInputRef>(null)
+  const discardPromptControl = Prompt.usePromptControl()
+  const {closeAllDialogs} = useDialogStateControlContext()
+
   const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
   const [isProcessing, setIsProcessing] = useState(false)
   const [processingState, setProcessingState] = useState('')
   const [error, setError] = useState('')
   const [richtext, setRichText] = useState(
     new RichText({
-      text: initMention
+      text: initText
+        ? initText
+        : initMention
         ? insertMentionAt(
             `@${initMention}`,
             initMention.length + 1,
@@ -110,7 +119,10 @@ export const ComposePost = observer(function ComposePost({
   const [labels, setLabels] = useState<string[]>([])
   const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
-  const gallery = useMemo(() => new GalleryModel(), [])
+  const gallery = useMemo(
+    () => new GalleryModel(initImageUris),
+    [initImageUris],
+  )
   const onClose = useCallback(() => {
     closeComposer()
   }, [closeComposer])
@@ -127,27 +139,21 @@ export const ComposePost = observer(function ComposePost({
 
   const onPressCancel = useCallback(() => {
     if (graphemeLength > 0 || !gallery.isEmpty) {
-      if (activeModals.some(modal => modal.name === 'confirm')) {
-        closeModal()
-      }
+      closeAllDialogs()
       if (Keyboard) {
         Keyboard.dismiss()
       }
-      openModal({
-        name: 'confirm',
-        title: _(msg`Discard draft`),
-        onPressConfirm: onClose,
-        onPressCancel: () => {
-          closeModal()
-        },
-        message: _(msg`Are you sure you'd like to discard this draft?`),
-        confirmBtnText: _(msg`Discard`),
-        confirmBtnStyle: {backgroundColor: colors.red4},
-      })
+      discardPromptControl.open()
     } else {
       onClose()
     }
-  }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _])
+  }, [
+    graphemeLength,
+    gallery.isEmpty,
+    closeAllDialogs,
+    discardPromptControl,
+    onClose,
+  ])
   // android back button
   useEffect(() => {
     if (!isAndroid) {
@@ -250,6 +256,16 @@ export const ComposePost = observer(function ComposePost({
       setIsProcessing(false)
       return
     } finally {
+      if (postUri) {
+        logEvent('post:create', {
+          imageCount: gallery.size,
+          isReply: replyTo != null,
+          hasLink: extLink != null,
+          hasQuote: quote != null,
+          langs: langPrefs.postLanguage,
+          logContext: 'Composer',
+        })
+      }
       track('Create Post', {
         imageCount: gallery.size,
       })
@@ -399,7 +415,11 @@ export const ComposePost = observer(function ComposePost({
               styles.textInputLayout,
               isNative && styles.textInputLayoutMobile,
             ]}>
-            <UserAvatar avatar={currentProfile?.avatar} size={50} />
+            <UserAvatar
+              avatar={currentProfile?.avatar}
+              size={50}
+              type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
+            />
             <TextInput
               ref={textInput}
               richtext={richtext}
@@ -481,6 +501,15 @@ export const ComposePost = observer(function ComposePost({
           <CharProgress count={graphemeLength} />
         </View>
       </View>
+
+      <Prompt.Basic
+        control={discardPromptControl}
+        title={_(msg`Discard draft?`)}
+        description={_(msg`Are you sure you'd like to discard this draft?`)}
+        onConfirm={onClose}
+        confirmButtonCta={_(msg`Discard`)}
+        confirmButtonColor="negative"
+      />
     </KeyboardAvoidingView>
   )
 })
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index 39a1473a3..0c1b87d04 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -15,7 +15,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {Text} from 'view/com/util/text/Text'
-import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed'
+import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed'
 
 export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
   const pal = usePalette('default')
@@ -86,7 +86,8 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
       <UserAvatar
         avatar={replyTo.author.avatar}
         size={50}
-        moderation={replyTo.moderation?.avatar}
+        moderation={replyTo.moderation?.ui('avatar')}
+        type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
       />
       <View style={styles.replyToPost}>
         <Text type="xl-medium" style={[pal.text]}>
@@ -103,7 +104,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
               {replyTo.text}
             </Text>
           </View>
-          {images && !replyTo.moderation?.embed.blur && (
+          {images && !replyTo.moderation?.ui('contentMedia').blur && (
             <ComposerReplyToImages images={images} showFull={showFull} />
           )}
         </View>
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index 632bb2634..16d1b6fb9 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -23,7 +23,11 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
       accessibilityRole="button"
       accessibilityLabel={_(msg`Compose reply`)}
       accessibilityHint={_(msg`Opens composer`)}>
-      <UserAvatar avatar={profile?.avatar} size={38} />
+      <UserAvatar
+        avatar={profile?.avatar}
+        size={38}
+        type={profile?.associated?.labeler ? 'labeler' : 'user'}
+      />
       <Text
         type="xl"
         style={[
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index a288e7310..4353704d5 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -1,5 +1,6 @@
 import React, {useCallback} from 'react'
 import {TouchableOpacity, StyleSheet} from 'react-native'
+import * as MediaLibrary from 'expo-media-library'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -24,6 +25,8 @@ export function OpenCameraBtn({gallery}: Props) {
   const {track} = useAnalytics()
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
+  const [mediaPermissionRes, requestMediaPermission] =
+    MediaLibrary.usePermissions()
 
   const onPressTakePicture = useCallback(async () => {
     track('Composer:CameraOpened')
@@ -31,6 +34,9 @@ export function OpenCameraBtn({gallery}: Props) {
       if (!(await requestCameraAccessIfNeeded())) {
         return
       }
+      if (!mediaPermissionRes?.granted && mediaPermissionRes?.canAskAgain) {
+        await requestMediaPermission()
+      }
 
       const img = await openCamera({
         width: POST_IMG_MAX.width,
@@ -38,12 +44,23 @@ export function OpenCameraBtn({gallery}: Props) {
         freeStyleCropEnabled: true,
       })
 
+      // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
+      // the image even without these permissions
+      if (mediaPermissionRes) {
+        await MediaLibrary.createAssetAsync(img.path)
+      }
       gallery.add(img)
     } catch (err: any) {
       // ignore
       logger.warn('Error using camera', {error: err})
     }
-  }, [gallery, track, requestCameraAccessIfNeeded])
+  }, [
+    gallery,
+    track,
+    requestCameraAccessIfNeeded,
+    mediaPermissionRes,
+    requestMediaPermission,
+  ])
 
   const shouldShowCameraButton = isNative || isMobileWeb
   if (!shouldShowCameraButton) {
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 78b1e9ba2..785622225 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -20,7 +20,7 @@ import {
   toPostLanguages,
   hasPostLanguage,
 } from '#/state/preferences/languages'
-import {t, msg} from '@lingui/macro'
+import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 export function SelectLangBtn() {
@@ -84,15 +84,15 @@ export function SelectLangBtn() {
     }
 
     return [
-      {heading: true, label: t`Post language`},
+      {heading: true, label: _(msg`Post language`)},
       ...arr.slice(0, 6),
       {sep: true},
       {
-        label: t`Other...`,
+        label: _(msg`Other...`),
         onPress: onPressMore,
       },
     ]
-  }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref])
+  }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _])
 
   return (
     <DropdownButton
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 17f9513b7..20be585c2 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl(
     let i = 0
 
     return Array.from(richtext.segments()).map(segment => {
-      const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
       return (
         <Text
           key={i++}
           style={[
-            segment.facet && !isTag ? pal.link : pal.text,
+            segment.facet ? pal.link : pal.text,
             styles.textInputFormatting,
           ]}>
           {segment.text}
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 199f1f749..c62d11201 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -23,6 +23,7 @@ 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 {TagDecorator} from './web/TagDecorator'
 
 export interface TextInputRef {
   focus: () => void
@@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     () => [
       Document,
       LinkDecorator,
+      TagDecorator,
       Mention.configure({
         HTMLAttributes: {
           class: 'mention',
diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
index c400aa48d..9c8f8f916 100644
--- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -78,7 +78,11 @@ export function Autocomplete({
                   accessibilityLabel={`Select ${item.handle}`}
                   accessibilityHint="">
                   <View style={styles.avatarAndHandle}>
-                    <UserAvatar avatar={item.avatar ?? null} size={24} />
+                    <UserAvatar
+                      avatar={item.avatar ?? null}
+                      size={24}
+                      type={item.associated?.labeler ? 'labeler' : 'user'}
+                    />
                     <Text type="md-medium" style={pal.text}>
                       {displayName}
                     </Text>
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index 76058fed3..29b8f0bc6 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -175,7 +175,11 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
                   }}
                   accessibilityRole="button">
                   <View style={styles.avatarAndDisplayName}>
-                    <UserAvatar avatar={item.avatar ?? null} size={26} />
+                    <UserAvatar
+                      avatar={item.avatar ?? null}
+                      size={26}
+                      type={item.associated?.labeler ? 'labeler' : 'user'}
+                    />
                     <Text style={pal.text} numberOfLines={1}>
                       {displayName}
                     </Text>
diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts
index 19945de08..e36ac80e4 100644
--- a/src/view/com/composer/text-input/web/LinkDecorator.ts
+++ b/src/view/com/composer/text-input/web/LinkDecorator.ts
@@ -18,6 +18,8 @@ import {Mark} from '@tiptap/core'
 import {Plugin, PluginKey} from '@tiptap/pm/state'
 import {Node as ProsemirrorNode} from '@tiptap/pm/model'
 import {Decoration, DecorationSet} from '@tiptap/pm/view'
+import {URL_REGEX} from '@atproto/api'
+
 import {isValidDomain} from 'lib/strings/url-helpers'
 
 export const LinkDecorator = Mark.create({
@@ -78,8 +80,7 @@ function linkDecorator() {
 
 function iterateUris(str: string, cb: (from: number, to: number) => void) {
   let match
-  const re =
-    /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
+  const re = URL_REGEX
   while ((match = re.exec(str))) {
     let uri = match[2]
     if (!uri.startsWith('http')) {
diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts
new file mode 100644
index 000000000..2bf3184a8
--- /dev/null
+++ b/src/view/com/composer/text-input/web/TagDecorator.ts
@@ -0,0 +1,91 @@
+/**
+ * TipTap is a stateful rich-text editor, which is extremely useful
+ * when you _want_ it to be stateful formatting such as bold and italics.
+ *
+ * However we also use "stateless" behaviors, specifically for URLs
+ * where the text itself drives the formatting.
+ *
+ * This plugin uses a regex to detect URIs and then applies
+ * link decorations (a <span> with the "autolink") class. That avoids
+ * adding any stateful formatting to TipTap's document model.
+ *
+ * We then run the URI detection again when constructing the
+ * RichText object from TipTap's output and merge their features into
+ * the facet-set.
+ */
+
+import {Mark} from '@tiptap/core'
+import {Plugin, PluginKey} from '@tiptap/pm/state'
+import {Node as ProsemirrorNode} from '@tiptap/pm/model'
+import {Decoration, DecorationSet} from '@tiptap/pm/view'
+import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api'
+
+function getDecorations(doc: ProsemirrorNode) {
+  const decorations: Decoration[] = []
+
+  doc.descendants((node, pos) => {
+    if (node.isText && node.text) {
+      const regex = TAG_REGEX
+      const textContent = node.textContent
+
+      let match
+      while ((match = regex.exec(textContent))) {
+        const [matchedString, _, tag] = match
+
+        if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64)
+          continue
+
+        const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || []
+        const matchedFrom = match.index + matchedString.indexOf(tag)
+        const matchedTo = matchedFrom + (tag.length - trailingPunc.length)
+
+        /*
+         * The match is exclusive of `#` so we need to adjust the start of the
+         * highlight by -1 to include the `#`
+         */
+        const start = pos + matchedFrom - 1
+        const end = pos + matchedTo
+
+        decorations.push(
+          Decoration.inline(start, end, {
+            class: 'autolink',
+          }),
+        )
+      }
+    }
+  })
+
+  return DecorationSet.create(doc, decorations)
+}
+
+const tagDecoratorPlugin: Plugin = new Plugin({
+  key: new PluginKey('link-decorator'),
+
+  state: {
+    init: (_, {doc}) => getDecorations(doc),
+    apply: (transaction, decorationSet) => {
+      if (transaction.docChanged) {
+        return getDecorations(transaction.doc)
+      }
+      return decorationSet.map(transaction.mapping, transaction.doc)
+    },
+  },
+
+  props: {
+    decorations(state) {
+      return tagDecoratorPlugin.getState(state)
+    },
+  },
+})
+
+export const TagDecorator = Mark.create({
+  name: 'tag-decorator',
+  priority: 1000,
+  keepOnSplit: false,
+  inclusive() {
+    return true
+  },
+  addProseMirrorPlugins() {
+    return [tagDecoratorPlugin]
+  },
+})