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.tsx8
-rw-r--r--src/view/com/composer/select-language/SuggestedLanguage.tsx101
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx123
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx3
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts8
5 files changed, 222 insertions, 21 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index e24fdcf3e..1ed6b98a5 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -45,6 +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 {SuggestedLanguage} from './select-language/SuggestedLanguage'
 import {insertMentionAt} from 'lib/strings/mention-manip'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -73,7 +74,7 @@ export const ComposePost = observer(function ComposePost({
 }: 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) => {
@@ -454,6 +455,7 @@ export const ComposePost = observer(function ComposePost({
               ))}
           </View>
         ) : null}
+        <SuggestedLanguage text={richtext.text} />
         <View style={[pal.border, styles.bottomBar]}>
           {canSelectImages ? (
             <>
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.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index ec3a042a3..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,6 +18,11 @@ 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
@@ -53,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,
@@ -68,7 +77,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       Placeholder.configure({
         placeholder,
       }),
-      Text,
+      TiptapText,
       History,
       Hardbreak,
     ],
@@ -88,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,
@@ -177,9 +226,28 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   }))
 
   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>
+      )}
+    </>
   )
 })
 
@@ -210,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(
@@ -218,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/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 6d16403ff..149362116 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -121,7 +121,8 @@ export function EmojiPicker({state, close}: IProps) {
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore web ony
+    position: 'fixed',
     top: 0,
     left: 0,
     right: 0,
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}
 }