diff options
Diffstat (limited to 'src/view/com/composer')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 92 | ||||
-rw-r--r-- | src/view/com/composer/ComposerReplyTo.tsx | 254 | ||||
-rw-r--r-- | src/view/com/composer/ExternalEmbed.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/Prompt.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/photos/OpenCameraBtn.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/photos/SelectPhotoBtn.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/select-language/SuggestedLanguage.tsx | 101 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 3 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 128 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/Autocomplete.tsx | 3 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/EmojiPicker.web.tsx | 143 | ||||
-rw-r--r-- | src/view/com/composer/useExternalLinkFetch.ts | 8 |
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} } |