about summary refs log tree commit diff
path: root/src/view/com/composer/Composer.tsx
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-03-13 16:01:43 -0500
committerGitHub <noreply@github.com>2023-03-13 16:01:43 -0500
commit56cf890debeb9872f791ccb992a5587f2c05fd9e (patch)
tree929453b41274a712d8b2fce441e98a0cd030d305 /src/view/com/composer/Composer.tsx
parent503e03d91e1de4bfeabec1eb2d97dcdceb13fcc5 (diff)
downloadvoidsky-56cf890debeb9872f791ccb992a5587f2c05fd9e.tar.zst
Move to expo and react-navigation (#288)
* WIP - adding expo

* WIP - adding expo 2

* Fix tsc

* Finish adding expo

* Disable the 'require cycle' warning

* Tweak plist

* Modify some dependency versions to make expo happy

* Fix icon fill

* Get Web compiling for expo

* 1.7

* Switch to react-navigation in expo2 (#287)

* WIP Switch to react-navigation

* WIP Switch to react-navigation 2

* WIP Switch to react-navigation 3

* Convert all screens to react navigation

* Update BottomBar for react navigation

* Update mobile menu to be react-native drawer

* Fixes to drawer and bottombar

* Factor out some helpers

* Replace the navigation model with react-navigation

* Restructure the shell folder and fix the header positioning

* Restore the error boundary

* Fix tsc

* Implement not-found page

* Remove react-native-gesture-handler (no longer used)

* Handle notifee card presses

* Handle all navigations from the state layer

* Fix drawer behaviors

* Fix two linking issues

* Switch to our react-native-progress fork to fix an svg rendering issue

* Get Web working with react-navigation

* Refactor routes and navigation for a bit more clarity

* Remove dead code

* Rework Web shell to left/right nav to make this easier

* Fix ViewHeader for desktop web

* Hide profileheader back btn on desktop web

* Move the compose button to the left nav

* Implement reply prompt in threads for desktop web

* Composer refactors

* Factor out all platform-specific text input behaviors from the composer

* Small fix

* Update the web build to use tiptap for the composer

* Tune up the mention autocomplete dropdown

* Simplify the default avatar and banner

* Fixes to link cards in web composer

* Fix dropdowns on web

* Tweak load latest on desktop

* Add web beta message and feedback link

* Fix up links in desktop web
Diffstat (limited to 'src/view/com/composer/Composer.tsx')
-rw-r--r--src/view/com/composer/Composer.tsx425
1 files changed, 425 insertions, 0 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
new file mode 100644
index 000000000..e9b728d73
--- /dev/null
+++ b/src/view/com/composer/Composer.tsx
@@ -0,0 +1,425 @@
+import React, {useEffect, useRef, useState} from 'react'
+import {observer} from 'mobx-react-lite'
+import {
+  ActivityIndicator,
+  KeyboardAvoidingView,
+  Platform,
+  SafeAreaView,
+  ScrollView,
+  StyleSheet,
+  TouchableOpacity,
+  TouchableWithoutFeedback,
+  View,
+} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useAnalytics} from 'lib/analytics'
+import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
+import {ExternalEmbed} from './ExternalEmbed'
+import {Text} from '../util/text/Text'
+import * as Toast from '../util/Toast'
+import {TextInput, TextInputRef} from './text-input/TextInput'
+import {CharProgress} from './char-progress/CharProgress'
+import {UserAvatar} from '../util/UserAvatar'
+import {useStores} from 'state/index'
+import * as apilib from 'lib/api/index'
+import {ComposerOpts} from 'state/models/shell-ui'
+import {s, colors, gradients} from 'lib/styles'
+import {cleanError} from 'lib/strings/errors'
+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 {useExternalLinkFetch} from './useExternalLinkFetch'
+
+const MAX_TEXT_LENGTH = 256
+
+export const ComposePost = observer(function ComposePost({
+  replyTo,
+  onPost,
+  onClose,
+  quote: initQuote,
+}: {
+  replyTo?: ComposerOpts['replyTo']
+  onPost?: ComposerOpts['onPost']
+  onClose: () => void
+  quote?: ComposerOpts['quote']
+}) {
+  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>(
+    initQuote,
+  )
+  const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
+  const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
+  const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
+
+  const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
+    () => new UserAutocompleteViewModel(store),
+    [store],
+  )
+
+  // HACK
+  // there's a bug with @mattermost/react-native-paste-input where if the input
+  // is focused during unmount, an exception will throw (seems that a blur method isnt implemented)
+  // manually blurring before closing gets around that
+  // -prf
+  const hackfixOnClose = React.useCallback(() => {
+    textInput.current?.blur()
+    onClose()
+  }, [textInput, onClose])
+
+  // initial setup
+  useEffect(() => {
+    autocompleteView.setup()
+  }, [autocompleteView])
+
+  useEffect(() => {
+    // HACK
+    // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
+    // -prf
+    let to: NodeJS.Timeout | undefined
+    if (textInput.current) {
+      to = setTimeout(() => {
+        textInput.current?.focus()
+      }, 250)
+    }
+    return () => {
+      if (to) {
+        clearTimeout(to)
+      }
+    }
+  }, [])
+
+  const onPressContainer = React.useCallback(() => {
+    textInput.current?.focus()
+  }, [textInput])
+
+  const onSelectPhotos = React.useCallback(
+    (photos: string[]) => {
+      track('Composer:SelectedPhotos')
+      setSelectedPhotos(photos)
+    },
+    [track, setSelectedPhotos],
+  )
+
+  const onPressAddLinkCard = React.useCallback(
+    (uri: string) => {
+      setExtLink({uri, isLoading: true})
+    },
+    [setExtLink],
+  )
+
+  const onPhotoPasted = React.useCallback(
+    async (uri: string) => {
+      if (selectedPhotos.length >= 4) {
+        return
+      }
+      onSelectPhotos([...selectedPhotos, uri])
+    },
+    [selectedPhotos, onSelectPhotos],
+  )
+
+  const onPressPublish = React.useCallback(async () => {
+    if (isProcessing) {
+      return
+    }
+    if (text.length > MAX_TEXT_LENGTH) {
+      return
+    }
+    setError('')
+    if (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,
+        replyTo: replyTo?.uri,
+        images: selectedPhotos,
+        quote: quote,
+        extLink: extLink,
+        onStateChange: setProcessingState,
+        knownHandles: autocompleteView.knownHandles,
+      })
+      track('Create Post', {
+        imageCount: selectedPhotos.length,
+      })
+    } catch (e: any) {
+      if (extLink) {
+        setExtLink({
+          ...extLink,
+          isLoading: true,
+          localThumb: undefined,
+        } as apilib.ExternalEmbedDraft)
+      }
+      setError(cleanError(e.message))
+      setIsProcessing(false)
+      return
+    }
+    store.me.mainFeed.loadLatest()
+    onPost?.()
+    hackfixOnClose()
+    Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
+  }, [
+    isProcessing,
+    text,
+    setError,
+    setIsProcessing,
+    replyTo,
+    autocompleteView.knownHandles,
+    extLink,
+    hackfixOnClose,
+    onPost,
+    quote,
+    selectedPhotos,
+    setExtLink,
+    store,
+    track,
+  ])
+
+  const canPost = text.length <= MAX_TEXT_LENGTH
+
+  const selectTextInputLayout =
+    selectedPhotos.length !== 0
+      ? styles.textInputLayoutWithPhoto
+      : styles.textInputLayoutWithoutPhoto
+  const selectTextInputPlaceholder = replyTo
+    ? 'Write your reply'
+    : selectedPhotos.length !== 0
+    ? 'Write a comment'
+    : "What's up?"
+
+  return (
+    <KeyboardAvoidingView
+      testID="composePostView"
+      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
+      style={styles.outer}>
+      <TouchableWithoutFeedback onPressIn={onPressContainer}>
+        <SafeAreaView style={s.flex1}>
+          <View style={styles.topbar}>
+            <TouchableOpacity
+              testID="composerCancelButton"
+              onPress={hackfixOnClose}>
+              <Text style={[pal.link, s.f18]}>Cancel</Text>
+            </TouchableOpacity>
+            <View style={s.flex1} />
+            {isProcessing ? (
+              <View style={styles.postBtn}>
+                <ActivityIndicator />
+              </View>
+            ) : canPost ? (
+              <TouchableOpacity
+                testID="composerPublishButton"
+                onPress={onPressPublish}>
+                <LinearGradient
+                  colors={[gradients.blueLight.start, gradients.blueLight.end]}
+                  start={{x: 0, y: 0}}
+                  end={{x: 1, y: 1}}
+                  style={styles.postBtn}>
+                  <Text style={[s.white, s.f16, s.bold]}>
+                    {replyTo ? 'Reply' : 'Post'}
+                  </Text>
+                </LinearGradient>
+              </TouchableOpacity>
+            ) : (
+              <View style={[styles.postBtn, pal.btn]}>
+                <Text style={[pal.textLight, s.f16, s.bold]}>Post</Text>
+              </View>
+            )}
+          </View>
+          {isProcessing ? (
+            <View style={[pal.btn, styles.processingLine]}>
+              <Text style={pal.text}>{processingState}</Text>
+            </View>
+          ) : undefined}
+          {error !== '' && (
+            <View style={styles.errorLine}>
+              <View style={styles.errorIcon}>
+                <FontAwesomeIcon
+                  icon="exclamation"
+                  style={{color: colors.red4}}
+                  size={10}
+                />
+              </View>
+              <Text style={[s.red4, s.flex1]}>{error}</Text>
+            </View>
+          )}
+          <ScrollView style={s.flex1}>
+            {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]}>
+                    {replyTo.author.displayName || replyTo.author.handle}
+                  </Text>
+                  <Text type="post-text" style={pal.text} numberOfLines={6}>
+                    {replyTo.text}
+                  </Text>
+                </View>
+              </View>
+            ) : undefined}
+
+            <View
+              style={[
+                pal.border,
+                styles.textInputLayout,
+                selectTextInputLayout,
+              ]}>
+              <UserAvatar avatar={store.me.avatar} size={50} />
+              <TextInput
+                ref={textInput}
+                text={text}
+                placeholder={selectTextInputPlaceholder}
+                suggestedLinks={suggestedLinks}
+                autocompleteView={autocompleteView}
+                onTextChanged={setText}
+                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 && (
+              <ExternalEmbed
+                link={extLink}
+                onRemove={() => setExtLink(undefined)}
+              />
+            )}
+          </ScrollView>
+          {!extLink &&
+          selectedPhotos.length === 0 &&
+          suggestedLinks.size > 0 &&
+          !quote ? (
+            <View style={s.mb5}>
+              {Array.from(suggestedLinks).map(url => (
+                <TouchableOpacity
+                  key={`suggested-${url}`}
+                  style={[pal.borderDark, styles.addExtLinkBtn]}
+                  onPress={() => onPressAddLinkCard(url)}>
+                  <Text style={pal.text}>
+                    Add link card: <Text style={pal.link}>{url}</Text>
+                  </Text>
+                </TouchableOpacity>
+              ))}
+            </View>
+          ) : null}
+          <View style={[pal.border, styles.bottomBar]}>
+            <SelectPhotoBtn
+              enabled={!quote && selectedPhotos.length < 4}
+              selectedPhotos={selectedPhotos}
+              onSelectPhotos={setSelectedPhotos}
+            />
+            <OpenCameraBtn
+              enabled={!quote && selectedPhotos.length < 4}
+              selectedPhotos={selectedPhotos}
+              onSelectPhotos={setSelectedPhotos}
+            />
+            <View style={s.flex1} />
+            <CharProgress count={text.length} />
+          </View>
+        </SafeAreaView>
+      </TouchableWithoutFeedback>
+    </KeyboardAvoidingView>
+  )
+})
+
+const styles = StyleSheet.create({
+  outer: {
+    flexDirection: 'column',
+    flex: 1,
+    padding: 15,
+    height: '100%',
+  },
+  topbar: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingBottom: 10,
+    paddingHorizontal: 5,
+    height: 55,
+  },
+  postBtn: {
+    borderRadius: 20,
+    paddingHorizontal: 20,
+    paddingVertical: 6,
+  },
+  processingLine: {
+    borderRadius: 6,
+    paddingHorizontal: 8,
+    paddingVertical: 6,
+    marginBottom: 6,
+  },
+  errorLine: {
+    flexDirection: 'row',
+    backgroundColor: colors.red1,
+    borderRadius: 6,
+    paddingHorizontal: 8,
+    paddingVertical: 6,
+    marginVertical: 6,
+  },
+  errorIcon: {
+    borderWidth: 1,
+    borderColor: colors.red4,
+    color: colors.red4,
+    borderRadius: 30,
+    width: 16,
+    height: 16,
+    alignItems: 'center',
+    justifyContent: 'center',
+    marginRight: 5,
+  },
+  textInputLayoutWithPhoto: {
+    flexWrap: 'wrap',
+  },
+  textInputLayoutWithoutPhoto: {
+    flex: 1,
+  },
+  textInputLayout: {
+    flexDirection: 'row',
+    borderTopWidth: 1,
+    paddingTop: 16,
+  },
+  replyToLayout: {
+    flexDirection: 'row',
+    borderTopWidth: 1,
+    paddingTop: 16,
+    paddingBottom: 16,
+  },
+  replyToPost: {
+    flex: 1,
+    paddingLeft: 13,
+    paddingRight: 8,
+  },
+  addExtLinkBtn: {
+    borderWidth: 1,
+    borderRadius: 24,
+    paddingHorizontal: 16,
+    paddingVertical: 12,
+    marginBottom: 4,
+  },
+  bottomBar: {
+    flexDirection: 'row',
+    paddingVertical: 10,
+    paddingRight: 5,
+    alignItems: 'center',
+    borderTopWidth: 1,
+  },
+})