diff options
Diffstat (limited to 'src/view/com/composer')
26 files changed, 323 insertions, 230 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 1899966dc..e4b09cf0f 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -56,12 +56,18 @@ import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {EmbeddingDisabledError} from '#/lib/api/resolve' import {until} from '#/lib/async/until' -import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' +import { + MAX_GRAPHEME_LENGTH, + SUPPORTED_MIME_TYPES, + SupportedMimeTypes, +} from '#/lib/constants' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {useEmail} from '#/lib/hooks/useEmail' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {mimeToExt} from '#/lib/media/video/util' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {colors, s} from '#/lib/styles' @@ -110,6 +116,8 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -127,6 +135,8 @@ import { ThreadDraft, } from './state/composer' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' +import {getVideoMetadata} from './videos/pickVideo' +import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' type CancelRef = { onPressCancel: () => void @@ -246,7 +256,8 @@ export const ComposePost = ({ const onClose = useCallback(() => { closeComposer() - }, [closeComposer]) + clearThumbnailCache(queryClient) + }, [closeComposer, queryClient]) const insets = useSafeAreaInsets() const viewStyles = useMemo( @@ -297,6 +308,15 @@ export const ComposePost = ({ } }, [onPressCancel, closeAllDialogs, closeAllModals]) + const {needsEmailVerification} = useEmail() + const emailVerificationControl = useDialogControl() + + useEffect(() => { + if (needsEmailVerification) { + emailVerificationControl.open() + } + }, [needsEmailVerification, emailVerificationControl]) + const missingAltError = useMemo(() => { if (!requireAltTextEnabled) { return @@ -570,6 +590,15 @@ export const ComposePost = ({ const isWebFooterSticky = !isNative && thread.posts.length > 1 return ( <BottomSheetPortalProvider> + <VerifyEmailDialog + control={emailVerificationControl} + onCloseWithoutVerifying={() => { + onClose() + }} + reasonText={_( + msg`Before creating a post, you must first verify your email.`, + )} + /> <KeyboardAvoidingView testID="composePostView" behavior={isIOS ? 'padding' : 'height'} @@ -723,14 +752,24 @@ let ComposerPost = React.memo(function ComposerPost({ const onPhotoPasted = useCallback( async (uri: string) => { - if (uri.startsWith('data:video/')) { - onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0}) + if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) { + if (isNative) return // web only + const [mimeType] = uri.slice('data:'.length).split(';') + if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { + Toast.show(_(msg`Unsupported video type`), 'xmark') + return + } + const name = `pasted.${mimeToExt(mimeType)}` + const file = await fetch(uri) + .then(res => res.blob()) + .then(blob => new File([blob], name, {type: mimeType})) + onSelectVideo(post.id, await getVideoMetadata(file)) } else { const res = await pasteImage(uri) onImageAdd([res]) } }, - [post.id, onSelectVideo, onImageAdd], + [post.id, onSelectVideo, onImageAdd, _], ) return ( @@ -986,17 +1025,6 @@ function ComposerEmbeds({ asset={video.asset} video={video.video} isActivePost={isActivePost} - setDimensions={(width: number, height: number) => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_dimensions', - width, - height, - signal: video.abortController.signal, - }, - }) - }} clear={clearVideo} /> ) : null)} @@ -1244,12 +1272,12 @@ function useScrollTracker({ const contentHeight = useSharedValue(0) const hasScrolledToTop = useDerivedValue(() => - withTiming(contentOffset.value === 0 ? 1 : 0), + withTiming(contentOffset.get() === 0 ? 1 : 0), ) const hasScrolledToBottom = useDerivedValue(() => withTiming( - contentHeight.value - contentOffset.value - 5 <= scrollViewHeight.value + contentHeight.get() - contentOffset.get() - 5 <= scrollViewHeight.get() ? 1 : 0, ), @@ -1267,11 +1295,11 @@ function useScrollTracker({ }) => { 'worklet' if (typeof newContentHeight === 'number') - contentHeight.value = Math.floor(newContentHeight) + contentHeight.set(Math.floor(newContentHeight)) if (typeof newContentOffset === 'number') - contentOffset.value = Math.floor(newContentOffset) + contentOffset.set(Math.floor(newContentOffset)) if (typeof newScrollViewHeight === 'number') - scrollViewHeight.value = Math.floor(newScrollViewHeight) + scrollViewHeight.set(Math.floor(newScrollViewHeight)) }, [contentHeight, contentOffset, scrollViewHeight], ) @@ -1287,21 +1315,22 @@ function useScrollTracker({ }, }) - const onScrollViewContentSizeChange = useCallback( - (_width: number, height: number) => { - if (stickyBottom && height > contentHeight.value) { + const onScrollViewContentSizeChangeUIThread = useCallback( + (newContentHeight: number) => { + 'worklet' + const oldContentHeight = contentHeight.get() + let shouldScrollToBottom = false + if (stickyBottom && newContentHeight > oldContentHeight) { const isFairlyCloseToBottom = - contentHeight.value - contentOffset.value - 100 <= - scrollViewHeight.value + oldContentHeight - contentOffset.get() - 100 <= scrollViewHeight.get() if (isFairlyCloseToBottom) { - runOnUI(() => { - scrollTo(scrollViewRef, 0, contentHeight.value, true) - })() + shouldScrollToBottom = true } } - showHideBottomBorder({ - newContentHeight: height, - }) + showHideBottomBorder({newContentHeight}) + if (shouldScrollToBottom) { + scrollTo(scrollViewRef, 0, newContentHeight, true) + } }, [ showHideBottomBorder, @@ -1313,6 +1342,13 @@ function useScrollTracker({ ], ) + const onScrollViewContentSizeChange = useCallback( + (_width: number, height: number) => { + runOnUI(onScrollViewContentSizeChangeUIThread)(height) + }, + [onScrollViewContentSizeChangeUIThread], + ) + const onScrollViewLayout = useCallback( (evt: LayoutChangeEvent) => { showHideBottomBorder({ @@ -1326,7 +1362,7 @@ function useScrollTracker({ return { borderBottomWidth: StyleSheet.hairlineWidth, borderColor: interpolateColor( - hasScrolledToTop.value, + hasScrolledToTop.get(), [0, 1], [t.atoms.border_contrast_medium.borderColor, 'transparent'], ), @@ -1336,7 +1372,7 @@ function useScrollTracker({ return { borderTopWidth: StyleSheet.hairlineWidth, borderColor: interpolateColor( - hasScrolledToBottom.value, + hasScrolledToBottom.get(), [0, 1], [t.atoms.border_contrast_medium.borderColor, 'transparent'], ), @@ -1581,7 +1617,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) { const animatedStyle = useAnimatedStyle(() => { return { - transform: [{rotateZ: `${rotate.value}deg`}], + transform: [{rotateZ: `${rotate.get()}deg`}], } }) diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx index 3ef9dad47..92102f847 100644 --- a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx +++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index ef5f8a3a5..bd99b9f28 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react' +import {useState} from 'react' import {TouchableOpacity, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index c7d9628d6..f2734e4ec 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, TextStyle, View, ViewStyle} from 'react-native' // @ts-ignore no type definition -prf import ProgressCircle from 'react-native-progress/Circle' diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 75eaa33d7..0718a1928 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Keyboard, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -158,22 +157,26 @@ function DialogInner({ <Toggle.Item name="porn" label={_(msg`Porn`)}> <Toggle.Checkbox /> <Toggle.LabelText> - <Trans>Porn</Trans> + <Trans>Adult</Trans> </Toggle.LabelText> </Toggle.Item> </View> </Toggle.Group> - <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}> - {labels.includes('sexual') ? ( - <Trans>Pictures meant for adults.</Trans> - ) : labels.includes('nudity') ? ( - <Trans>Artistic or non-erotic nudity.</Trans> - ) : labels.includes('porn') ? ( - <Trans>Sexual activity or erotic nudity.</Trans> - ) : ( - <Trans>Does not contain adult content.</Trans> - )} - </Text> + {labels.includes('sexual') || + labels.includes('nudity') || + labels.includes('porn') ? ( + <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}> + {labels.includes('sexual') ? ( + <Trans>Pictures meant for adults.</Trans> + ) : labels.includes('nudity') ? ( + <Trans>Artistic or non-erotic nudity.</Trans> + ) : labels.includes('porn') ? ( + <Trans>Sexual activity or erotic nudity.</Trans> + ) : ( + '' + )} + </Text> + ) : null} </View> </View> <View> @@ -203,16 +206,14 @@ function DialogInner({ </Toggle.LabelText> </Toggle.Item> </Toggle.Group> - <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}> - {labels.includes('graphic-media') ? ( + {labels.includes('graphic-media') ? ( + <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}> <Trans> Media that may be disturbing or inappropriate for some audiences. </Trans> - ) : ( - <Trans>Does not contain graphic or disturbing content.</Trans> - )} - </Text> + </Text> + ) : null} </View> </View> </View> diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 79d59a92d..fb3ab5c8f 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react' +import {useCallback} from 'react' import * as MediaLibrary from 'expo-media-library' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx index 74f9acdc6..3fb0e00d2 100644 --- a/src/view/com/composer/photos/SelectGifBtn.tsx +++ b/src/view/com/composer/photos/SelectGifBtn.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useRef} from 'react' +import {useCallback, useRef} from 'react' import {Keyboard} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 37bfbafe6..f4c6aa328 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */ -import React, {useCallback} from 'react' +import {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx index 94dbc35c6..cd3cb608d 100644 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ b/src/view/com/composer/select-language/SelectLangBtn.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react' +import {useCallback, useMemo} from 'react' import {Keyboard, StyleSheet} from 'react-native' import { FontAwesomeIcon, diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx index e915f4c66..6d55aeb53 100644 --- a/src/view/com/composer/select-language/SuggestedLanguage.tsx +++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react' +import {useEffect, useState} from 'react' import {StyleSheet, View} from 'react-native' import { FontAwesomeIcon, diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 8814a7e61..7ce4a0cf8 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -37,12 +37,6 @@ export type VideoAction = } | {type: 'update_progress'; progress: number; signal: AbortSignal} | { - type: 'update_dimensions' - width: number - height: number - signal: AbortSignal - } - | { type: 'update_alt_text' altText: string signal: AbortSignal @@ -185,13 +179,6 @@ export function videoReducer( progress: action.progress, } } - } else if (action.type === 'update_dimensions') { - if (state.asset) { - return { - ...state, - asset: {...state.asset, width: action.width, height: action.height}, - } - } } else if (action.type === 'update_alt_text') { return { ...state, diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 10cf1a931..96cecb37c 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -31,7 +31,7 @@ import { suggestLinkCardUri, } from '#/view/com/composer/text-input/text-input-util' import {atoms as a, useAlf} from '#/alf' -import {normalizeTextStyles} from '#/components/Typography' +import {normalizeTextStyles} from '#/alf/typography' import {Autocomplete} from './mobile/Autocomplete' export interface TextInputRef { diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index fa742d258..8ec4fefa8 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -11,6 +11,7 @@ import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' import {Text as TiptapText} from '@tiptap/extension-text' import {generateJSON} from '@tiptap/html' +import {Fragment, Node, Slice} from '@tiptap/pm/model' import {EditorContent, JSONContent, useEditor} from '@tiptap/react' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' @@ -23,8 +24,8 @@ import { } from '#/view/com/composer/text-input/text-input-util' import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a, useAlf} from '#/alf' +import {normalizeTextStyles} from '#/alf/typography' import {Portal} from '#/components/Portal' -import {normalizeTextStyles} from '#/components/Typography' import {Text} from '../../util/text/Text' import {createSuggestion} from './web/Autocomplete' import {Emoji} from './web/EmojiPicker.web' @@ -166,6 +167,11 @@ export const TextInput = React.forwardRef(function TextInputImpl( const editor = useEditor( { extensions, + coreExtensionOptions: { + clipboardTextSerializer: { + blockSeparator: '\n', + }, + }, onFocus() { onFocus?.() }, @@ -173,6 +179,20 @@ export const TextInput = React.forwardRef(function TextInputImpl( attributes: { class: modeClass, }, + clipboardTextParser: (text, context) => { + const blocks = text.split(/(?:\r\n?|\n)/) + const nodes: Node[] = blocks.map(line => { + return Node.fromJSON( + context.doc.type.schema, + line.length > 0 + ? {type: 'paragraph', content: [{type: 'text', text: line}]} + : {type: 'paragraph', content: []}, + ) + }) + + const fragment = Fragment.fromArray(nodes) + return Slice.maxOpen(fragment) + }, handlePaste: (view, event) => { const clipboardData = event.clipboardData let preventDefault = false @@ -205,6 +225,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( autofocus: 'end', editable: true, injectCSS: true, + shouldRerenderOnTransaction: false, onCreate({editor: editorProp}) { // HACK // the 'enter' animation sometimes causes autofocus to fail @@ -297,15 +318,9 @@ export const TextInput = React.forwardRef(function TextInputImpl( style.lineHeight = style.lineHeight ? ((style.lineHeight + 'px') as unknown as number) : undefined + style.minHeight = webForceMinHeight ? 140 : undefined return style - }, [t, fonts]) - - React.useLayoutEffect(() => { - let node = editor?.view.dom - if (node) { - node.style.minHeight = webForceMinHeight ? '140px' : '' - } - }, [editor, webForceMinHeight]) + }, [t, fonts, webForceMinHeight]) return ( <> diff --git a/src/view/com/composer/text-input/hooks/useGrapheme.tsx b/src/view/com/composer/text-input/hooks/useGrapheme.tsx index 01b5b9698..aa375ff47 100644 --- a/src/view/com/composer/text-input/hooks/useGrapheme.tsx +++ b/src/view/com/composer/text-input/hooks/useGrapheme.tsx @@ -13,7 +13,7 @@ export const useGrapheme = () => { if (graphemes.length > length) { remainingCharacters = 0 - name = `${graphemes.slice(0, length).join('')}...` + name = `${graphemes.slice(0, length).join('')}…` } else { remainingCharacters = length - graphemes.length name = graphemes.join('') diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index 3d2bcfa61..0fda6843b 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -1,7 +1,5 @@ -import React, {useRef} from 'react' import {View} from 'react-native' import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated' -import {AppBskyActorDefs} from '@atproto/api' import {Trans} from '@lingui/macro' import {PressableScale} from '#/lib/custom-animations/PressableScale' @@ -11,7 +9,6 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' -import {useGrapheme} from '../hooks/useGrapheme' export function Autocomplete({ prefix, @@ -22,15 +19,11 @@ export function Autocomplete({ }) { const t = useTheme() - const {getGraphemeString} = useGrapheme() const isActive = !!prefix - const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix) - const suggestionsRef = useRef< - AppBskyActorDefs.ProfileViewBasic[] | undefined - >(undefined) - if (suggestions) { - suggestionsRef.current = suggestions - } + const {data: suggestions, isFetching} = useActorAutocompleteQuery( + prefix, + true, + ) if (!isActive) return null @@ -46,26 +39,8 @@ export function Autocomplete({ t.atoms.border_contrast_high, {marginLeft: -62}, ]}> - {suggestionsRef.current?.length ? ( - suggestionsRef.current.slice(0, 5).map((item, index, arr) => { - // Eventually use an average length - const MAX_CHARS = 40 - const MAX_HANDLE_CHARS = 20 - - // Using this approach because styling is not respecting - // bounding box wrapping (before converting to ellipsis) - const {name: displayHandle, remainingCharacters} = getGraphemeString( - item.handle, - MAX_HANDLE_CHARS, - ) - - const {name: displayName} = getGraphemeString( - item.displayName || item.handle, - MAX_CHARS - - MAX_HANDLE_CHARS + - (remainingCharacters > 0 ? remainingCharacters : 0), - ) - + {suggestions?.length ? ( + suggestions.slice(0, 5).map((item, index, arr) => { return ( <View style={[ @@ -93,15 +68,23 @@ export function Autocomplete({ type={item.associated?.labeler ? 'labeler' : 'user'} /> <Text - style={[a.text_md, a.font_bold]} - emoji={true} + style={[a.flex_1, a.text_md, a.font_bold]} + emoji + numberOfLines={1}> + {sanitizeDisplayName( + item.displayName || sanitizeHandle(item.handle), + )} + </Text> + <Text + style={[ + t.atoms.text_contrast_medium, + a.text_right, + {maxWidth: '50%'}, + ]} numberOfLines={1}> - {sanitizeDisplayName(displayName)} + {sanitizeHandle(item.handle, '@')} </Text> </View> - <Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}> - {sanitizeHandle(displayHandle, '@')} - </Text> </PressableScale> </View> ) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index a43e67c04..f40c2ee8d 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -1,9 +1,4 @@ -import React, { - forwardRef, - useEffect, - useImperativeHandle, - useState, -} from 'react' +import {forwardRef, useEffect, useImperativeHandle, useState} from 'react' import {Pressable, StyleSheet, View} from 'react-native' import {Trans} from '@lingui/macro' import {ReactRenderer} from '@tiptap/react' @@ -15,6 +10,8 @@ import { import tippy, {Instance as TippyInstance} from 'tippy.js' import {usePalette} from '#/lib/hooks/usePalette' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {Text} from '#/view/com/util/text/Text' import {UserAvatar} from '#/view/com/util/UserAvatar' @@ -153,7 +150,9 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( {items.length > 0 ? ( items.map((item, index) => { const {name: displayName} = getGraphemeString( - item.displayName ?? item.handle, + sanitizeDisplayName( + item.displayName || sanitizeHandle(item.handle), + ), 30, // Heuristic value; can be modified ) const isSelected = selectedIndex === index @@ -186,7 +185,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( </Text> </View> <Text type="xs" style={pal.textLight} numberOfLines={1}> - @{item.handle} + {sanitizeHandle(item.handle, '@')} </Text> </Pressable> ) diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 78bf8c06f..4130cc7e4 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Keyboard, StyleProp, ViewStyle} from 'react-native' import {AnimatedStyle} from 'react-native-reanimated' import {AppBskyFeedPostgate} from '@atproto/api' diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index 2ba003a6d..1b052ccdd 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -1,11 +1,6 @@ -import React, {useCallback} from 'react' +import {useCallback} from 'react' import {Keyboard} from 'react-native' -import { - ImagePickerAsset, - launchImageLibraryAsync, - MediaTypeOptions, - UIImagePickerPreferredAssetRepresentationMode, -} from 'expo-image-picker' +import {ImagePickerAsset} from 'expo-image-picker' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog' import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' import * as Prompt from '#/components/Prompt' +import {pickVideo} from './pickVideo' const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds @@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { Keyboard.dismiss() control.open() } else { - const response = await launchImageLibraryAsync({ - exif: false, - mediaTypes: MediaTypeOptions.Videos, - quality: 1, - legacy: true, - preferredAssetRepresentationMode: - UIImagePickerPreferredAssetRepresentationMode.Current, - }) + const response = await pickVideo() if (response.assets && response.assets.length > 0) { const asset = response.assets[0] try { if (isWeb) { + // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) + if (asset.duration && asset.duration > VIDEO_MAX_DURATION) { + throw Error(_(msg`Videos must be less than 60 seconds long`)) + } // compression step on native converts to mp4, so no need to check there - const mimeType = getMimeType(asset) if ( - !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes) + !SUPPORTED_MIME_TYPES.includes( + asset.mimeType as SupportedMimeTypes, + ) ) { - throw Error(_(msg`Unsupported video type: ${mimeType}`)) + throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) } } else { if (typeof asset.duration !== 'number') { @@ -142,17 +136,3 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { </> ) } - -function getMimeType(asset: ImagePickerAsset) { - if (isWeb) { - const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') - if (!mimeType) { - throw new Error('Could not determine mime type') - } - return mimeType - } - if (!asset.mimeType) { - throw new Error('Could not determine mime type') - } - return asset.mimeType -} diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx index 27c3de02b..e907dc41c 100644 --- a/src/view/com/composer/videos/SubtitleDialog.tsx +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useState} from 'react' import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' import RNPickerSelect from 'react-native-picker-select' import {msg, Trans} from '@lingui/macro' diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index fff7545a5..255174bea 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -20,7 +20,6 @@ export function VideoPreview({ asset: ImagePickerAsset video: CompressedVideo isActivePost: boolean - setDimensions: (width: number, height: number) => void clear: () => void }) { const t = useTheme() diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx index ccb3391c2..f20f8b383 100644 --- a/src/view/com/composer/videos/VideoPreview.web.tsx +++ b/src/view/com/composer/videos/VideoPreview.web.tsx @@ -1,4 +1,3 @@ -import React, {useEffect, useRef} from 'react' import {View} from 'react-native' import {ImagePickerAsset} from 'expo-image-picker' import {msg} from '@lingui/macro' @@ -12,58 +11,22 @@ import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -const MAX_DURATION = 60 - export function VideoPreview({ asset, video, - setDimensions, + clear, }: { asset: ImagePickerAsset video: CompressedVideo - setDimensions: (width: number, height: number) => void + clear: () => void }) { - const ref = useRef<HTMLVideoElement>(null) const {_} = useLingui() + // TODO: figure out how to pause a GIF for reduced motion + // it's not possible using an img tag -sfn const autoplayDisabled = useAutoplayDisabled() - useEffect(() => { - if (!ref.current) return - - const abortController = new AbortController() - const {signal} = abortController - ref.current.addEventListener( - 'loadedmetadata', - function () { - setDimensions(this.videoWidth, this.videoHeight) - if (!isNaN(this.duration)) { - if (this.duration > MAX_DURATION) { - Toast.show( - _(msg`Videos must be less than 60 seconds long`), - 'xmark', - ) - clear() - } - } - }, - {signal}, - ) - ref.current.addEventListener( - 'error', - () => { - Toast.show(_(msg`Could not process your video`), 'xmark') - clear() - }, - {signal}, - ) - - return () => { - abortController.abort() - } - }, [setDimensions, _, clear]) - let aspectRatio = asset.width / asset.height if (isNaN(aspectRatio)) { @@ -83,19 +46,34 @@ export function VideoPreview({ a.relative, ]}> <ExternalEmbedRemoveBtn onRemove={clear} /> - <video - ref={ref} - src={video.uri} - style={{width: '100%', height: '100%', objectFit: 'cover'}} - autoPlay={!autoplayDisabled} - loop - muted - playsInline - /> - {autoplayDisabled && ( - <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> - <PlayButtonIcon /> - </View> + {video.mimeType === 'image/gif' ? ( + <img + src={video.uri} + style={{width: '100%', height: '100%', objectFit: 'cover'}} + alt="GIF" + /> + ) : ( + <> + <video + src={video.uri} + style={{width: '100%', height: '100%', objectFit: 'cover'}} + autoPlay={!autoplayDisabled} + loop + muted + playsInline + onError={err => { + console.error('Error loading video', err) + Toast.show(_(msg`Could not process your video`), 'xmark') + clear() + }} + /> + {autoplayDisabled && ( + <View + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> + <PlayButtonIcon /> + </View> + )} + </> )} </View> ) diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx index ef38e62af..caf0b38e2 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx @@ -1,25 +1,25 @@ -import React, {useEffect} from 'react' import {clearCache, createVideoThumbnail} from 'react-native-compressor' import Animated, {FadeIn} from 'react-native-reanimated' import {Image} from 'expo-image' -import {useQuery} from '@tanstack/react-query' +import {QueryClient, useQuery} from '@tanstack/react-query' import {atoms as a} from '#/alf' +export const RQKEY = 'video-thumbnail' + +export function clearThumbnailCache(queryClient: QueryClient) { + clearCache() + queryClient.resetQueries({queryKey: [RQKEY]}) +} + export function VideoTranscodeBackdrop({uri}: {uri: string}) { const {data: thumbnail} = useQuery({ - queryKey: ['thumbnail', uri], + queryKey: [RQKEY, uri], queryFn: async () => { return await createVideoThumbnail(uri) }, }) - useEffect(() => { - return () => { - clearCache() - } - }, []) - return ( thumbnail && ( <Animated.View style={a.flex_1} entering={FadeIn}> diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx index d4090d853..a04200f53 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx @@ -1,3 +1,7 @@ +export function clearThumbnailCache() { + // no-op +} + export function VideoTranscodeBackdrop() { return null } diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx index f6f0f7ccf..f408be720 100644 --- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' // @ts-expect-error no type definition import ProgressPie from 'react-native-progress/Pie' diff --git a/src/view/com/composer/videos/pickVideo.ts b/src/view/com/composer/videos/pickVideo.ts new file mode 100644 index 000000000..0edf7d0de --- /dev/null +++ b/src/view/com/composer/videos/pickVideo.ts @@ -0,0 +1,21 @@ +import { + ImagePickerAsset, + launchImageLibraryAsync, + MediaTypeOptions, + UIImagePickerPreferredAssetRepresentationMode, +} from 'expo-image-picker' + +export async function pickVideo() { + return await launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Videos, + quality: 1, + legacy: true, + preferredAssetRepresentationMode: + UIImagePickerPreferredAssetRepresentationMode.Current, + }) +} + +export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => { + throw new Error('getVideoMetadata is web only') +} diff --git a/src/view/com/composer/videos/pickVideo.web.ts b/src/view/com/composer/videos/pickVideo.web.ts new file mode 100644 index 000000000..56a38fa56 --- /dev/null +++ b/src/view/com/composer/videos/pickVideo.web.ts @@ -0,0 +1,94 @@ +import {ImagePickerAsset, ImagePickerResult} from 'expo-image-picker' + +import {SUPPORTED_MIME_TYPES} from '#/lib/constants' + +// mostly copied from expo-image-picker and adapted to support gifs +// also adds support for reading video metadata + +export async function pickVideo(): Promise<ImagePickerResult> { + const input = document.createElement('input') + input.style.display = 'none' + input.setAttribute('type', 'file') + // TODO: do we need video/* here? -sfn + input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(',')) + input.setAttribute('id', String(Math.random())) + + document.body.appendChild(input) + + return new Promise(resolve => { + input.addEventListener('change', async () => { + if (input.files) { + const file = input.files[0] + resolve({ + canceled: false, + assets: [await getVideoMetadata(file)], + }) + } else { + resolve({canceled: true, assets: null}) + } + document.body.removeChild(input) + }) + + const event = new MouseEvent('click') + input.dispatchEvent(event) + }) +} + +// TODO: we're converting to a dataUrl here, and then converting back to an +// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it +// lets us use the ImagePickerAsset type, which the rest of the code expects. +// We should unwind this and just pass the ArrayBuffer/objectUrl through the system +// instead of a string -sfn +export const getVideoMetadata = (file: File): Promise<ImagePickerAsset> => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const uri = reader.result as string + + if (file.type === 'image/gif') { + const img = new Image() + img.onload = () => { + resolve({ + uri, + mimeType: 'image/gif', + width: img.width, + height: img.height, + // todo: calculate gif duration. seems possible if you read the bytes + // https://codepen.io/Ryman/pen/nZpYwY + // for now let's just let the server reject it, since that seems uncommon -sfn + duration: null, + }) + } + img.onerror = (_ev, _source, _lineno, _colno, error) => { + console.log('Failed to grab GIF metadata', error) + reject(new Error('Failed to grab GIF metadata')) + } + img.src = uri + } else { + const video = document.createElement('video') + const blobUrl = URL.createObjectURL(file) + + video.preload = 'metadata' + video.src = blobUrl + + video.onloadedmetadata = () => { + URL.revokeObjectURL(blobUrl) + resolve({ + uri, + mimeType: file.type, + width: video.videoWidth, + height: video.videoHeight, + // convert seconds to ms + duration: video.duration * 1000, + }) + } + video.onerror = (_ev, _source, _lineno, _colno, error) => { + URL.revokeObjectURL(blobUrl) + console.log('Failed to grab video metadata', error) + reject(new Error('Failed to grab video metadata')) + } + } + } + reader.readAsDataURL(file) + }) +} |