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.tsx92
-rw-r--r--src/view/com/composer/ComposerReplyTo.tsx254
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx2
-rw-r--r--src/view/com/composer/Prompt.tsx2
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx2
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx2
-rw-r--r--src/view/com/composer/select-language/SuggestedLanguage.tsx101
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx3
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx128
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx3
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx143
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts8
12 files changed, 618 insertions, 122 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 9f60923d6..1ed6b98a5 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -6,6 +6,7 @@ import {
   Keyboard,
   KeyboardAvoidingView,
   Platform,
+  Pressable,
   ScrollView,
   StyleSheet,
   TouchableOpacity,
@@ -28,8 +29,6 @@ import {UserAvatar} from '../util/UserAvatar'
 import * as apilib from 'lib/api/index'
 import {ComposerOpts} from 'state/shell/composer'
 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'
@@ -46,7 +45,7 @@ import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
 import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
-import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
+import {SuggestedLanguage} from './select-language/SuggestedLanguage'
 import {insertMentionAt} from 'lib/strings/mention-manip'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -63,6 +62,7 @@ import {useComposerControls} from '#/state/shell/composer'
 import {emitPostCreated} from '#/state/events'
 import {ThreadgateSetting} from '#/state/queries/threadgate'
 import {logger} from '#/logger'
+import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -70,10 +70,11 @@ export const ComposePost = observer(function ComposePost({
   onPost,
   quote: initQuote,
   mention: initMention,
+  openPicker,
 }: Props) {
   const {currentAccount} = useSession()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
-  const {activeModals} = useModals()
+  const {isModalActive, activeModals} = useModals()
   const {openModal, closeModal} = useModalControls()
   const {closeComposer} = useComposerControls()
   const {track} = useAnalytics()
@@ -175,11 +176,11 @@ export const ComposePost = observer(function ComposePost({
     [onPressCancel],
   )
   useEffect(() => {
-    if (isWeb) {
+    if (isWeb && !isModalActive) {
       window.addEventListener('keydown', onEscape)
       return () => window.removeEventListener('keydown', onEscape)
     }
-  }, [onEscape])
+  }, [onEscape, isModalActive])
 
   const onPressAddLinkCard = useCallback(
     (uri: string) => {
@@ -260,7 +261,11 @@ export const ComposePost = observer(function ComposePost({
     setLangPrefs.savePostLanguageToHistory()
     onPost?.()
     onClose()
-    Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
+    Toast.show(
+      replyTo
+        ? _(msg`Your reply has been published`)
+        : _(msg`Your post has been published`),
+    )
   }
 
   const canPost = useMemo(
@@ -269,11 +274,17 @@ export const ComposePost = observer(function ComposePost({
       (!requireAltTextEnabled || !gallery.needsAltText),
     [graphemeLength, requireAltTextEnabled, gallery.needsAltText],
   )
-  const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
+  const selectTextInputPlaceholder = replyTo
+    ? _(msg`Write your reply`)
+    : _(msg`What's up?`)
 
   const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
   const hasMedia = gallery.size > 0 || Boolean(extLink)
 
+  const onEmojiButtonPress = useCallback(() => {
+    openPicker?.(textInput.current?.getCursorPosition())
+  }, [openPicker])
+
   return (
     <KeyboardAvoidingView
       testID="composePostView"
@@ -287,7 +298,9 @@ export const ComposePost = observer(function ComposePost({
             onAccessibilityEscape={onPressCancel}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Cancel`)}
-            accessibilityHint="Closes post composer and discards post draft">
+            accessibilityHint={_(
+              msg`Closes post composer and discards post draft`,
+            )}>
             <Text style={[pal.link, s.f18]}>
               <Trans>Cancel</Trans>
             </Text>
@@ -319,7 +332,7 @@ export const ComposePost = observer(function ComposePost({
                   onPress={onPressPublish}
                   accessibilityRole="button"
                   accessibilityLabel={
-                    replyTo ? 'Publish reply' : 'Publish post'
+                    replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
                   }
                   accessibilityHint="">
                   <LinearGradient
@@ -331,14 +344,18 @@ export const ComposePost = observer(function ComposePost({
                     end={{x: 1, y: 1}}
                     style={styles.postBtn}>
                     <Text style={[s.white, s.f16, s.bold]}>
-                      {replyTo ? 'Reply' : 'Post'}
+                      {replyTo ? (
+                        <Trans context="action">Reply</Trans>
+                      ) : (
+                        <Trans context="action">Post</Trans>
+                      )}
                     </Text>
                   </LinearGradient>
                 </TouchableOpacity>
               ) : (
                 <View style={[styles.postBtn, pal.btn]}>
                   <Text style={[pal.textLight, s.f16, s.bold]}>
-                    <Trans>Post</Trans>
+                    <Trans context="action">Post</Trans>
                   </Text>
                 </View>
               )}
@@ -374,22 +391,7 @@ export const ComposePost = observer(function ComposePost({
         <ScrollView
           style={styles.scrollView}
           keyboardShouldPersistTaps="always">
-          {replyTo ? (
-            <View style={[pal.border, styles.replyToLayout]}>
-              <UserAvatar avatar={replyTo.author.avatar} size={50} />
-              <View style={styles.replyToPost}>
-                <Text type="xl-medium" style={[pal.text]}>
-                  {sanitizeDisplayName(
-                    replyTo.author.displayName ||
-                      sanitizeHandle(replyTo.author.handle),
-                  )}
-                </Text>
-                <Text type="post-text" style={pal.text} numberOfLines={6}>
-                  {replyTo.text}
-                </Text>
-              </View>
-            </View>
-          ) : undefined}
+          {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
 
           <View
             style={[
@@ -411,7 +413,9 @@ export const ComposePost = observer(function ComposePost({
               onError={setError}
               accessible={true}
               accessibilityLabel={_(msg`Write post`)}
-              accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`}
+              accessibilityHint={_(
+                msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
+              )}
             />
           </View>
 
@@ -440,7 +444,9 @@ export const ComposePost = observer(function ComposePost({
                   onPress={() => onPressAddLinkCard(url)}
                   accessibilityRole="button"
                   accessibilityLabel={_(msg`Add link card`)}
-                  accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
+                  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>
@@ -449,6 +455,7 @@ export const ComposePost = observer(function ComposePost({
               ))}
           </View>
         ) : null}
+        <SuggestedLanguage text={richtext.text} />
         <View style={[pal.border, styles.bottomBar]}>
           {canSelectImages ? (
             <>
@@ -456,7 +463,19 @@ export const ComposePost = observer(function ComposePost({
               <OpenCameraBtn gallery={gallery} />
             </>
           ) : null}
-          {!isMobile ? <EmojiPickerButton /> : null}
+          {!isMobile ? (
+            <Pressable
+              onPress={onEmojiButtonPress}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Open emoji picker`)}
+              accessibilityHint={_(msg`Open emoji picker`)}>
+              <FontAwesomeIcon
+                icon={['far', 'face-smile']}
+                color={pal.colors.link}
+                size={22}
+              />
+            </Pressable>
+          ) : null}
           <View style={s.flex1} />
           <SelectLangBtn />
           <CharProgress count={graphemeLength} />
@@ -532,17 +551,6 @@ const styles = StyleSheet.create({
   textInputLayoutMobile: {
     flex: 1,
   },
-  replyToLayout: {
-    flexDirection: 'row',
-    borderTopWidth: 1,
-    paddingTop: 16,
-    paddingBottom: 16,
-  },
-  replyToPost: {
-    flex: 1,
-    paddingLeft: 13,
-    paddingRight: 8,
-  },
   addExtLinkBtn: {
     borderWidth: 1,
     borderRadius: 24,
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
new file mode 100644
index 000000000..678c8581f
--- /dev/null
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -0,0 +1,254 @@
+import React from 'react'
+import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native'
+import {Image} from 'expo-image'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {
+  AppBskyEmbedImages,
+  AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyFeedPost,
+} from '@atproto/api'
+import {ComposerOptsPostRef} from 'state/shell/composer'
+import {usePalette} from 'lib/hooks/usePalette'
+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'
+
+export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {embed} = replyTo
+
+  const [showFull, setShowFull] = React.useState(false)
+
+  const onPress = React.useCallback(() => {
+    setShowFull(prev => !prev)
+    LayoutAnimation.configureNext({
+      duration: 350,
+      update: {type: 'spring', springDamping: 0.7},
+    })
+  }, [])
+
+  const quote = React.useMemo(() => {
+    if (
+      AppBskyEmbedRecord.isView(embed) &&
+      AppBskyEmbedRecord.isViewRecord(embed.record) &&
+      AppBskyFeedPost.isRecord(embed.record.value)
+    ) {
+      // Not going to include the images right now
+      return {
+        author: embed.record.author,
+        cid: embed.record.cid,
+        uri: embed.record.uri,
+        indexedAt: embed.record.indexedAt,
+        text: embed.record.value.text,
+      }
+    } else if (
+      AppBskyEmbedRecordWithMedia.isView(embed) &&
+      AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
+      AppBskyFeedPost.isRecord(embed.record.record.value)
+    ) {
+      return {
+        author: embed.record.record.author,
+        cid: embed.record.record.cid,
+        uri: embed.record.record.uri,
+        indexedAt: embed.record.record.indexedAt,
+        text: embed.record.record.value.text,
+      }
+    }
+  }, [embed])
+
+  const images = React.useMemo(() => {
+    if (AppBskyEmbedImages.isView(embed)) {
+      return embed.images
+    } else if (
+      AppBskyEmbedRecordWithMedia.isView(embed) &&
+      AppBskyEmbedImages.isView(embed.media)
+    ) {
+      return embed.media.images
+    }
+  }, [embed])
+
+  return (
+    <Pressable
+      style={[pal.border, styles.replyToLayout]}
+      onPress={onPress}
+      accessibilityRole="button"
+      accessibilityLabel={_(
+        msg`Expand or collapse the full post you are replying to`,
+      )}
+      accessibilityHint={_(
+        msg`Expand or collapse the full post you are replying to`,
+      )}>
+      <UserAvatar avatar={replyTo.author.avatar} size={50} />
+      <View style={styles.replyToPost}>
+        <Text type="xl-medium" style={[pal.text]}>
+          {sanitizeDisplayName(
+            replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
+          )}
+        </Text>
+        <View style={styles.replyToBody}>
+          <View style={styles.replyToText}>
+            <Text
+              type="post-text"
+              style={pal.text}
+              numberOfLines={!showFull ? 6 : undefined}>
+              {replyTo.text}
+            </Text>
+          </View>
+          {images && (
+            <ComposerReplyToImages images={images} showFull={showFull} />
+          )}
+        </View>
+        {showFull && quote && <QuoteEmbed quote={quote} />}
+      </View>
+    </Pressable>
+  )
+}
+
+function ComposerReplyToImages({
+  images,
+}: {
+  images: AppBskyEmbedImages.ViewImage[]
+  showFull: boolean
+}) {
+  return (
+    <View
+      style={{
+        width: 65,
+        flexDirection: 'column',
+        alignItems: 'center',
+      }}>
+      <View style={styles.imagesContainer}>
+        {(images.length === 1 && (
+          <Image
+            source={{uri: images[0].thumb}}
+            style={styles.singleImage}
+            cachePolicy="memory-disk"
+            accessibilityIgnoresInvertColors
+          />
+        )) ||
+          (images.length === 2 && (
+            <View style={[styles.imagesInner, styles.imagesRow]}>
+              <Image
+                source={{uri: images[0].thumb}}
+                style={styles.doubleImageTall}
+                cachePolicy="memory-disk"
+                accessibilityIgnoresInvertColors
+              />
+              <Image
+                source={{uri: images[1].thumb}}
+                style={styles.doubleImageTall}
+                cachePolicy="memory-disk"
+                accessibilityIgnoresInvertColors
+              />
+            </View>
+          )) ||
+          (images.length === 3 && (
+            <View style={[styles.imagesInner, styles.imagesRow]}>
+              <Image
+                source={{uri: images[0].thumb}}
+                style={styles.doubleImageTall}
+                cachePolicy="memory-disk"
+                accessibilityIgnoresInvertColors
+              />
+              <View style={styles.imagesInner}>
+                <Image
+                  source={{uri: images[1].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+                <Image
+                  source={{uri: images[2].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+              </View>
+            </View>
+          )) ||
+          (images.length === 4 && (
+            <View style={styles.imagesInner}>
+              <View style={[styles.imagesInner, styles.imagesRow]}>
+                <Image
+                  source={{uri: images[0].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+                <Image
+                  source={{uri: images[1].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+              </View>
+              <View style={[styles.imagesInner, styles.imagesRow]}>
+                <Image
+                  source={{uri: images[2].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+                <Image
+                  source={{uri: images[3].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+              </View>
+            </View>
+          ))}
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  replyToLayout: {
+    flexDirection: 'row',
+    borderTopWidth: 1,
+    paddingTop: 16,
+    paddingBottom: 16,
+  },
+  replyToPost: {
+    flex: 1,
+    paddingLeft: 13,
+    paddingRight: 8,
+  },
+  replyToBody: {
+    flexDirection: 'row',
+    gap: 10,
+  },
+  replyToText: {
+    flex: 1,
+    flexGrow: 1,
+  },
+  imagesContainer: {
+    borderRadius: 6,
+    overflow: 'hidden',
+    marginTop: 2,
+  },
+  imagesInner: {
+    gap: 2,
+  },
+  imagesRow: {
+    flexDirection: 'row',
+  },
+  singleImage: {
+    width: 65,
+    height: 65,
+  },
+  doubleImageTall: {
+    width: 32.5,
+    height: 65,
+  },
+  doubleImage: {
+    width: 32.5,
+    height: 32.5,
+  },
+})
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 502e4b4d2..02dd1bbd7 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -68,7 +68,7 @@ export const ExternalEmbed = ({
         onPress={onRemove}
         accessibilityRole="button"
         accessibilityLabel={_(msg`Remove image preview`)}
-        accessibilityHint={`Removes default thumbnail from ${link.uri}`}
+        accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)}
         onAccessibilityEscape={onRemove}>
         <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
       </TouchableOpacity>
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index 9964359ac..632bb2634 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -22,7 +22,7 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
       onPress={() => onPressCompose()}
       accessibilityRole="button"
       accessibilityLabel={_(msg`Compose reply`)}
-      accessibilityHint="Opens composer">
+      accessibilityHint={_(msg`Opens composer`)}>
       <UserAvatar avatar={profile?.avatar} size={38} />
       <Text
         type="xl"
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 69f63c55f..a288e7310 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -58,7 +58,7 @@ export function OpenCameraBtn({gallery}: Props) {
       hitSlop={HITSLOP_10}
       accessibilityRole="button"
       accessibilityLabel={_(msg`Camera`)}
-      accessibilityHint="Opens camera on device">
+      accessibilityHint={_(msg`Opens camera on device`)}>
       <FontAwesomeIcon
         icon="camera"
         style={pal.link as FontAwesomeIconStyle}
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index af0a22b01..f7fa9502d 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -41,7 +41,7 @@ export function SelectPhotoBtn({gallery}: Props) {
       hitSlop={HITSLOP_10}
       accessibilityRole="button"
       accessibilityLabel={_(msg`Gallery`)}
-      accessibilityHint="Opens device photo gallery">
+      accessibilityHint={_(msg`Opens device photo gallery`)}>
       <FontAwesomeIcon
         icon={['far', 'image']}
         style={pal.link as FontAwesomeIconStyle}
diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx
new file mode 100644
index 000000000..987d89d36
--- /dev/null
+++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx
@@ -0,0 +1,101 @@
+import React, {useEffect, useState} from 'react'
+import {StyleSheet, View} from 'react-native'
+import lande from 'lande'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {Text} from '../../util/text/Text'
+import {Button} from '../../util/forms/Button'
+import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
+import {
+  toPostLanguages,
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+} from '#/state/preferences/languages'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {s} from '#/lib/styles'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+
+// fallbacks for safari
+const onIdle = globalThis.requestIdleCallback || (cb => setTimeout(cb, 1))
+const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
+
+export function SuggestedLanguage({text}: {text: string}) {
+  const [suggestedLanguage, setSuggestedLanguage] = useState<string>()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+
+  useEffect(() => {
+    const textTrimmed = text.trim()
+
+    // Don't run the language model on small posts, the results are likely
+    // to be inaccurate anyway.
+    if (textTrimmed.length < 40) {
+      setSuggestedLanguage(undefined)
+      return
+    }
+
+    const idle = onIdle(() => {
+      // Only select languages that have a high confidence and convert to code2
+      const result = lande(textTrimmed).filter(
+        ([lang, value]) => value >= 0.97 && code3ToCode2Strict(lang),
+      )
+
+      setSuggestedLanguage(
+        result.length > 0 ? code3ToCode2Strict(result[0][0]) : undefined,
+      )
+    })
+
+    return () => cancelIdle(idle)
+  }, [text])
+
+  return suggestedLanguage &&
+    !toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage) ? (
+    <View style={[pal.border, styles.infoBar]}>
+      <FontAwesomeIcon
+        icon="language"
+        style={pal.text as FontAwesomeIconStyle}
+        size={24}
+      />
+      <Text style={[pal.text, s.flex1]}>
+        <Trans>
+          Are you writing in{' '}
+          <Text type="sm-bold" style={pal.text}>
+            {codeToLanguageName(suggestedLanguage)}
+          </Text>
+          ?
+        </Trans>
+      </Text>
+
+      <Button
+        type="default"
+        onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)}
+        accessibilityLabel={_(
+          msg`Change post language to ${codeToLanguageName(suggestedLanguage)}`,
+        )}
+        accessibilityHint="">
+        <Text type="button" style={[pal.link, s.fw600]}>
+          <Trans>Yes</Trans>
+        </Text>
+      </Button>
+    </View>
+  ) : null
+}
+
+const styles = StyleSheet.create({
+  infoBar: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 10,
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 16,
+    paddingVertical: 12,
+    marginHorizontal: 10,
+    marginBottom: 10,
+  },
+})
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 7e39f6aed..3d0d5ab8d 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -32,6 +32,7 @@ import {POST_IMG_MAX} from 'lib/constants'
 export interface TextInputRef {
   focus: () => void
   blur: () => void
+  getCursorPosition: () => DOMRect | undefined
 }
 
 interface TextInputProps extends ComponentProps<typeof RNTextInput> {
@@ -74,6 +75,7 @@ export const TextInput = forwardRef(function TextInputImpl(
     blur: () => {
       textInput.current?.blur()
     },
+    getCursorPosition: () => undefined, // Not implemented on native
   }))
 
   const onChangeText = useCallback(
@@ -215,6 +217,7 @@ export const TextInput = forwardRef(function TextInputImpl(
         autoFocus={true}
         allowFontScaling
         multiline
+        scrollEnabled={false}
         numberOfLines={4}
         style={[
           pal.text,
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 206a3205b..f2012a630 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -9,7 +9,7 @@ import Hardbreak from '@tiptap/extension-hard-break'
 import {Mention} from '@tiptap/extension-mention'
 import {Paragraph} from '@tiptap/extension-paragraph'
 import {Placeholder} from '@tiptap/extension-placeholder'
-import {Text} from '@tiptap/extension-text'
+import {Text as TiptapText} from '@tiptap/extension-text'
 import isEqual from 'lodash.isequal'
 import {createSuggestion} from './web/Autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
@@ -18,10 +18,16 @@ import {Emoji} from './web/EmojiPicker.web'
 import {LinkDecorator} from './web/LinkDecorator'
 import {generateJSON} from '@tiptap/html'
 import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {Portal} from '#/components/Portal'
+import {Text} from '../../util/text/Text'
+import {Trans} from '@lingui/macro'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
 
 export interface TextInputRef {
   focus: () => void
   blur: () => void
+  getCursorPosition: () => DOMRect | undefined
 }
 
 interface TextInputProps {
@@ -52,7 +58,11 @@ export const TextInput = React.forwardRef(function TextInputImpl(
 ) {
   const autocomplete = useActorAutocompleteFn()
 
+  const pal = usePalette('default')
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
+
+  const [isDropping, setIsDropping] = React.useState(false)
+
   const extensions = React.useMemo(
     () => [
       Document,
@@ -67,7 +77,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       Placeholder.configure({
         placeholder,
       }),
-      Text,
+      TiptapText,
       History,
       Hardbreak,
     ],
@@ -87,6 +97,46 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     }
   }, [onPhotoPasted])
 
+  React.useEffect(() => {
+    const handleDrop = (event: DragEvent) => {
+      const transfer = event.dataTransfer
+      if (transfer) {
+        const items = transfer.items
+
+        getImageFromUri(items, (uri: string) => {
+          textInputWebEmitter.emit('photo-pasted', uri)
+        })
+      }
+
+      event.preventDefault()
+      setIsDropping(false)
+    }
+    const handleDragEnter = (event: DragEvent) => {
+      const transfer = event.dataTransfer
+
+      event.preventDefault()
+      if (transfer && transfer.types.includes('Files')) {
+        setIsDropping(true)
+      }
+    }
+    const handleDragLeave = (event: DragEvent) => {
+      event.preventDefault()
+      setIsDropping(false)
+    }
+
+    document.body.addEventListener('drop', handleDrop)
+    document.body.addEventListener('dragenter', handleDragEnter)
+    document.body.addEventListener('dragover', handleDragEnter)
+    document.body.addEventListener('dragleave', handleDragLeave)
+
+    return () => {
+      document.body.removeEventListener('drop', handleDrop)
+      document.body.removeEventListener('dragenter', handleDragEnter)
+      document.body.removeEventListener('dragover', handleDragEnter)
+      document.body.removeEventListener('dragleave', handleDragLeave)
+    }
+  }, [setIsDropping])
+
   const editor = useEditor(
     {
       extensions,
@@ -169,12 +219,35 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   React.useImperativeHandle(ref, () => ({
     focus: () => {}, // TODO
     blur: () => {}, // TODO
+    getCursorPosition: () => {
+      const pos = editor?.state.selection.$anchor.pos
+      return pos ? editor?.view.coordsAtPos(pos) : undefined
+    },
   }))
 
   return (
-    <View style={styles.container}>
-      <EditorContent editor={editor} />
-    </View>
+    <>
+      <View style={styles.container}>
+        <EditorContent editor={editor} />
+      </View>
+
+      {isDropping && (
+        <Portal>
+          <Animated.View
+            style={styles.dropContainer}
+            entering={FadeIn.duration(80)}
+            exiting={FadeOut.duration(80)}>
+            <View style={[pal.view, pal.border, styles.dropModal]}>
+              <Text
+                type="lg"
+                style={[pal.text, pal.borderDark, styles.dropText]}>
+                <Trans>Drop to add images</Trans>
+              </Text>
+            </View>
+          </Animated.View>
+        </Portal>
+      )}
+    </>
   )
 })
 
@@ -205,6 +278,33 @@ const styles = StyleSheet.create({
     marginLeft: 8,
     marginBottom: 10,
   },
+  dropContainer: {
+    backgroundColor: '#0007',
+    pointerEvents: 'none',
+    alignItems: 'center',
+    justifyContent: 'center',
+    // @ts-ignore web only -prf
+    position: 'fixed',
+    padding: 16,
+    top: 0,
+    bottom: 0,
+    left: 0,
+    right: 0,
+  },
+  dropModal: {
+    // @ts-ignore web only
+    boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
+    padding: 8,
+    borderWidth: 1,
+    borderRadius: 16,
+  },
+  dropText: {
+    paddingVertical: 44,
+    paddingHorizontal: 36,
+    borderStyle: 'dashed',
+    borderRadius: 8,
+    borderWidth: 2,
+  },
 })
 
 function getImageFromUri(
@@ -213,25 +313,25 @@ function getImageFromUri(
 ) {
   for (let index = 0; index < items.length; index++) {
     const item = items[index]
-    const {kind, type} = item
+    const type = item.type
 
     if (type === 'text/plain') {
+      console.log('hit')
       item.getAsString(async itemString => {
         if (isUriImage(itemString)) {
           const response = await fetch(itemString)
           const blob = await response.blob()
-          blobToDataUri(blob).then(callback, err => console.error(err))
+
+          if (blob.type.startsWith('image/')) {
+            blobToDataUri(blob).then(callback, err => console.error(err))
+          }
         }
       })
-    }
-
-    if (kind === 'file') {
+    } else if (type.startsWith('image/')) {
       const file = item.getAsFile()
 
-      if (file instanceof Blob) {
-        blobToDataUri(new Blob([file], {type: item.type})).then(callback, err =>
-          console.error(err),
-        )
+      if (file) {
+        blobToDataUri(file).then(callback, err => console.error(err))
       }
     }
   }
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index 51197b8e4..76058fed3 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -17,6 +17,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {useGrapheme} from '../hooks/useGrapheme'
+import {Trans} from '@lingui/macro'
 
 interface MentionListRef {
   onKeyDown: (props: SuggestionKeyDownProps) => boolean
@@ -187,7 +188,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
             })
           ) : (
             <Text type="sm" style={[pal.text, styles.noResult]}>
-              No result
+              <Trans>No result</Trans>
             </Text>
           )}
         </View>
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index f4b2d99b0..149362116 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -1,11 +1,17 @@
 import React from 'react'
 import Picker from '@emoji-mart/react'
-import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {
+  StyleSheet,
+  TouchableWithoutFeedback,
+  useWindowDimensions,
+  View,
+} from 'react-native'
 import {textInputWebEmitter} from '../TextInput.web'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useMediaQuery} from 'react-responsive'
+
+const HEIGHT_OFFSET = 40
+const WIDTH_OFFSET = 100
+const PICKER_HEIGHT = 435 + HEIGHT_OFFSET
+const PICKER_WIDTH = 350 + WIDTH_OFFSET
 
 export type Emoji = {
   aliases?: string[]
@@ -18,59 +24,87 @@ export type Emoji = {
   unified: string
 }
 
-export function EmojiPickerButton() {
-  const pal = usePalette('default')
-  const [open, setOpen] = React.useState(false)
-  const onOpenChange = (o: boolean) => {
-    setOpen(o)
-  }
-  const close = () => {
-    setOpen(false)
-  }
+export interface EmojiPickerState {
+  isOpen: boolean
+  pos: {top: number; left: number; right: number; bottom: number}
+}
 
-  return (
-    <DropdownMenu.Root open={open} onOpenChange={onOpenChange}>
-      <DropdownMenu.Trigger style={styles.trigger}>
-        <FontAwesomeIcon
-          icon={['far', 'face-smile']}
-          color={pal.colors.link}
-          size={22}
-        />
-      </DropdownMenu.Trigger>
-
-      <DropdownMenu.Portal>
-        <EmojiPicker close={close} />
-      </DropdownMenu.Portal>
-    </DropdownMenu.Root>
-  )
+interface IProps {
+  state: EmojiPickerState
+  close: () => void
 }
 
-export function EmojiPicker({close}: {close: () => void}) {
+export function EmojiPicker({state, close}: IProps) {
+  const {height, width} = useWindowDimensions()
+
+  const isShiftDown = React.useRef(false)
+
+  const position = React.useMemo(() => {
+    const fitsBelow = state.pos.top + PICKER_HEIGHT < height
+    const fitsAbove = PICKER_HEIGHT < state.pos.top
+    const placeOnLeft = PICKER_WIDTH < state.pos.left
+    const screenYMiddle = height / 2 - PICKER_HEIGHT / 2
+
+    if (fitsBelow) {
+      return {
+        top: state.pos.top + HEIGHT_OFFSET,
+      }
+    } else if (fitsAbove) {
+      return {
+        bottom: height - state.pos.bottom + HEIGHT_OFFSET,
+      }
+    } else {
+      return {
+        top: screenYMiddle,
+        left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined,
+        right: !placeOnLeft
+          ? width - state.pos.right - PICKER_WIDTH
+          : undefined,
+      }
+    }
+  }, [state.pos, height, width])
+
+  React.useEffect(() => {
+    if (!state.isOpen) return
+
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Shift') {
+        isShiftDown.current = true
+      }
+    }
+    const onKeyUp = (e: KeyboardEvent) => {
+      if (e.key === 'Shift') {
+        isShiftDown.current = false
+      }
+    }
+    window.addEventListener('keydown', onKeyDown, true)
+    window.addEventListener('keyup', onKeyUp, true)
+
+    return () => {
+      window.removeEventListener('keydown', onKeyDown, true)
+      window.removeEventListener('keyup', onKeyUp, true)
+    }
+  }, [state.isOpen])
+
   const onInsert = (emoji: Emoji) => {
     textInputWebEmitter.emit('emoji-inserted', emoji)
-    close()
+
+    if (!isShiftDown.current) {
+      close()
+    }
   }
-  const reducedPadding = useMediaQuery({query: '(max-height: 750px)'})
-  const noPadding = useMediaQuery({query: '(max-height: 550px)'})
-  const noPicker = useMediaQuery({query: '(max-height: 350px)'})
+
+  if (!state.isOpen) return null
 
   return (
-    // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
-    <TouchableWithoutFeedback onPress={close} accessibilityViewIsModal>
+    <TouchableWithoutFeedback
+      accessibilityRole="button"
+      onPress={close}
+      accessibilityViewIsModal>
       <View style={styles.mask}>
         {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
-        <TouchableWithoutFeedback
-          onPress={e => {
-            e.stopPropagation() // prevent event from bubbling up to the mask
-          }}>
-          <View
-            style={[
-              styles.picker,
-              {
-                paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325,
-                display: noPicker ? 'none' : 'flex',
-              },
-            ]}>
+        <TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
+          <View style={[{position: 'absolute'}, position]}>
             <Picker
               data={async () => {
                 return (await import('./EmojiPickerData.json')).default
@@ -87,21 +121,14 @@ export function EmojiPicker({close}: {close: () => void}) {
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore web ony
+    position: 'fixed',
     top: 0,
     left: 0,
     right: 0,
     width: '100%',
     height: '100%',
-  },
-  trigger: {
-    backgroundColor: 'transparent',
-    // @ts-ignore web only -prf
-    border: 'none',
-    paddingTop: 4,
-    paddingLeft: 12,
-    paddingRight: 12,
-    cursor: 'pointer',
+    alignItems: 'center',
   },
   picker: {
     marginHorizontal: 'auto',
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index ef3958c9d..fc7218d5d 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -18,6 +18,7 @@ import {POST_IMG_MAX} from 'lib/constants'
 import {logger} from '#/logger'
 import {getAgent} from '#/state/session'
 import {useGetPost} from '#/state/queries/post'
+import {useFetchDid} from '#/state/queries/handle'
 
 export function useExternalLinkFetch({
   setQuote,
@@ -28,6 +29,7 @@ export function useExternalLinkFetch({
     undefined,
   )
   const getPost = useGetPost()
+  const fetchDid = useFetchDid()
 
   useEffect(() => {
     let aborted = false
@@ -55,7 +57,7 @@ export function useExternalLinkFetch({
           },
         )
       } else if (isBskyCustomFeedUrl(extLink.uri)) {
-        getFeedAsEmbed(getAgent(), extLink.uri).then(
+        getFeedAsEmbed(getAgent(), fetchDid, extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -73,7 +75,7 @@ export function useExternalLinkFetch({
           },
         )
       } else if (isBskyListUrl(extLink.uri)) {
-        getListAsEmbed(getAgent(), extLink.uri).then(
+        getListAsEmbed(getAgent(), fetchDid, extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -133,7 +135,7 @@ export function useExternalLinkFetch({
       })
     }
     return cleanup
-  }, [extLink, setQuote, getPost])
+  }, [extLink, setQuote, getPost, fetchDid])
 
   return {extLink, setExtLink}
 }