diff --git a/native/keyboard/keyboard-input-host.react.js b/native/keyboard/keyboard-input-host.react.js index 677b060ce..a8238d9ce 100644 --- a/native/keyboard/keyboard-input-host.react.js +++ b/native/keyboard/keyboard-input-host.react.js @@ -1,113 +1,122 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { TextInput } from 'react-native'; import { KeyboardAccessoryView } from 'react-native-keyboard-input'; import type { MediaLibrarySelection } from 'lib/types/media-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; import { type InputState, InputStateContext } from '../input/input-state'; import { mediaGalleryKeyboardName } from '../media/media-gallery-keyboard.react'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { useStyles } from '../themes/colors'; import { type KeyboardState, KeyboardContext } from './keyboard-state'; type BaseProps = { +textInputRef?: ?React.ElementRef, }; type Props = { ...BaseProps, // Redux state +styles: typeof unboundStyles, +activeMessageList: ?string, // withKeyboardState +keyboardState: KeyboardState, // withInputState +inputState: ?InputState, }; class KeyboardInputHost extends React.PureComponent { componentDidUpdate(prevProps: Props) { if ( prevProps.activeMessageList && this.props.activeMessageList !== prevProps.activeMessageList ) { this.hideMediaGallery(); } } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } render() { const kbComponent = KeyboardInputHost.mediaGalleryOpen(this.props) ? mediaGalleryKeyboardName : null; + const kbInitialProps = { + ...this.props.styles.kbInitialProps, + threadInfo: this.props.keyboardState.getMediaGalleryThread(), + }; return ( ); } onMediaGalleryItemSelected = async ( keyboardName: string, - selections: $ReadOnlyArray, + result: { + +selections: $ReadOnlyArray, + +threadInfo: ?ThreadInfo, + }, ) => { const { keyboardState } = this.props; keyboardState.dismissKeyboard(); - const mediaGalleryThread = keyboardState.getMediaGalleryThread(); + + const { selections, threadInfo: mediaGalleryThread } = result; if (!mediaGalleryThread) { return; } const { inputState } = this.props; invariant( inputState, 'inputState should be set in onMediaGalleryItemSelected', ); inputState.sendMultimediaMessage(selections, mediaGalleryThread); }; hideMediaGallery = () => { const { keyboardState } = this.props; keyboardState.hideMediaGallery(); }; } const unboundStyles = { kbInitialProps: { backgroundColor: 'listBackground', }, }; const ConnectedKeyboardInputHost: React.ComponentType = React.memo( function ConnectedKeyboardInputHost(props: BaseProps) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); invariant(keyboardState, 'keyboardState should be initialized'); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const activeMessageList = activeMessageListSelector(navContext); return ( ); }, ); export default ConnectedKeyboardInputHost; diff --git a/native/media/media-gallery-keyboard.react.js b/native/media/media-gallery-keyboard.react.js index 160ab4573..d960a1258 100644 --- a/native/media/media-gallery-keyboard.react.js +++ b/native/media/media-gallery-keyboard.react.js @@ -1,684 +1,681 @@ // @flow import * as ImagePicker from 'expo-image-picker'; import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, FlatList, ActivityIndicator, Animated, Easing, Platform, } from 'react-native'; import { KeyboardRegistry } from 'react-native-keyboard-input'; import { Provider } from 'react-redux'; import { extensionFromFilename, filenameFromPathOrURI, } from 'lib/media/file-utils'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import type { MediaLibrarySelection } from 'lib/types/media-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; import Button from '../components/button.react'; import type { DimensionsInfo } from '../redux/dimensions-updater.react'; import { store } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { LayoutEvent, ViewableItemsChange } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import { getCompatibleMediaURI } from './identifier-utils'; import MediaGalleryMedia from './media-gallery-media.react'; import SendMediaButton from './send-media-button.react'; const animationSpec = { duration: 400, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; +type BaseProps = { + +threadInfo: ?ThreadInfo, +}; type Props = { + ...BaseProps, // Redux state +dimensions: DimensionsInfo, +foreground: boolean, +colors: Colors, +styles: typeof unboundStyles, }; type State = { +selections: ?$ReadOnlyArray, +error: ?string, +containerHeight: ?number, // null means end reached; undefined means no fetch yet +cursor: ?string, +queuedMediaURIs: ?Set, +focusedMediaURI: ?string, +dimensions: DimensionsInfo, }; class MediaGalleryKeyboard extends React.PureComponent { mounted = false; fetchingPhotos = false; flatList: ?FlatList; viewableIndices: number[] = []; queueModeProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; mediaSelected = false; constructor(props: Props) { super(props); const sendButtonScale = this.queueModeProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.3, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.queueModeProgress, transform: [{ scale: sendButtonScale }], }; this.state = { selections: null, error: null, containerHeight: null, cursor: undefined, queuedMediaURIs: null, focusedMediaURI: null, dimensions: props.dimensions, }; } static getDerivedStateFromProps(props: Props) { // We keep this in state since we pass this.state as // FlatList's extraData prop return { dimensions: props.dimensions }; } componentDidMount() { this.mounted = true; return this.fetchPhotos(); } componentWillUnmount() { this.mounted = false; } componentDidUpdate(prevProps: Props, prevState: State) { const { queuedMediaURIs } = this.state; const prevQueuedMediaURIs = prevState.queuedMediaURIs; if (queuedMediaURIs && !prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 1, }).start(); } else if (!queuedMediaURIs && prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 0, }).start(); } const { flatList, viewableIndices } = this; const { selections, focusedMediaURI } = this.state; let scrollingSomewhere = false; if (flatList && selections) { let newURI; if (focusedMediaURI && focusedMediaURI !== prevState.focusedMediaURI) { newURI = focusedMediaURI; } else if ( queuedMediaURIs && (!prevQueuedMediaURIs || queuedMediaURIs.size > prevQueuedMediaURIs.size) ) { const flowMadeMeDoThis = queuedMediaURIs; for (const queuedMediaURI of flowMadeMeDoThis) { if (prevQueuedMediaURIs && prevQueuedMediaURIs.has(queuedMediaURI)) { continue; } newURI = queuedMediaURI; break; } } let index; if (newURI !== null && newURI !== undefined) { index = selections.findIndex(({ uri }) => uri === newURI); } if (index !== null && index !== undefined) { if (index === viewableIndices[0]) { scrollingSomewhere = true; flatList.scrollToIndex({ index }); } else if (index === viewableIndices[viewableIndices.length - 1]) { scrollingSomewhere = true; flatList.scrollToIndex({ index, viewPosition: 1 }); } } } if (this.props.foreground && !prevProps.foreground) { this.fetchPhotos(); } if ( !scrollingSomewhere && this.flatList && this.state.selections && prevState.selections && this.state.selections.length > 0 && prevState.selections.length > 0 && this.state.selections[0].uri !== prevState.selections[0].uri ) { this.flatList.scrollToIndex({ index: 0 }); } } guardedSetState(change) { if (this.mounted) { this.setState(change); } } async fetchPhotos(after?: ?string) { if (this.fetchingPhotos) { return; } this.fetchingPhotos = true; try { const hasPermission = await this.getPermissions(); if (!hasPermission) { return; } const { assets, endCursor, hasNextPage, } = await MediaLibrary.getAssetsAsync({ first: 20, after, mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video], sortBy: [MediaLibrary.SortBy.modificationTime], }); let firstRemoved = false, lastRemoved = false; const mediaURIs = this.state.selections ? this.state.selections.map(({ uri }) => uri) : []; const existingURIs = new Set(mediaURIs); let first = true; const selections = assets .map(asset => { const { id, height, width, filename, mediaType, duration } = asset; const isVideo = mediaType === MediaLibrary.MediaType.video; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(filename), ); if (existingURIs.has(uri)) { if (first) { firstRemoved = true; } lastRemoved = true; first = false; return null; } first = false; lastRemoved = false; existingURIs.add(uri); if (isVideo) { return { step: 'video_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, duration, selectTime: 0, sendTime: 0, retries: 0, }; } else { return { step: 'photo_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, selectTime: 0, sendTime: 0, retries: 0, }; } }) .filter(Boolean); let appendOrPrepend = after ? 'append' : 'prepend'; if (firstRemoved && !lastRemoved) { appendOrPrepend = 'append'; } else if (!firstRemoved && lastRemoved) { appendOrPrepend = 'prepend'; } let newSelections = selections; if (this.state.selections) { if (appendOrPrepend === 'prepend') { newSelections = [...newSelections, ...this.state.selections]; } else { newSelections = [...this.state.selections, ...newSelections]; } } this.guardedSetState({ selections: newSelections, error: null, cursor: hasNextPage ? endCursor : null, }); } catch (e) { this.guardedSetState({ selections: null, error: 'something went wrong :(', }); } this.fetchingPhotos = false; } openNativePicker = async () => { try { const { assets, canceled } = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, allowsEditing: false, allowsMultipleSelection: true, // maximum quality is 1 - it disables compression quality: 1, // we don't want to compress videos at this point videoExportPreset: ImagePicker.VideoExportPreset.Passthrough, }); if (canceled || assets.length === 0) { return; } const selections = assets.map(asset => { const { width, height, fileName, type, duration, assetId: mediaNativeID, } = asset; const isVideo = type === 'video'; const filename = fileName || filenameFromPathOrURI(asset.uri) || ''; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(filename), ); if (isVideo) { return { step: 'video_library', dimensions: { height, width }, uri, filename, mediaNativeID, duration, selectTime: 0, sendTime: 0, retries: 0, }; } else { return { step: 'photo_library', dimensions: { height, width }, uri, filename, mediaNativeID, selectTime: 0, sendTime: 0, retries: 0, }; } }); - const selectionURIs = selections.map(({ uri }) => uri); - this.guardedSetState(prevState => ({ - error: null, - selections: [...selections, ...(prevState.selections ?? [])], - queuedMediaURIs: new Set([ - ...selectionURIs, - ...(prevState.queuedMediaURIs ?? []), - ]), - focusedMediaURI: null, - })); + this.sendMedia(selections); } catch (e) { if (__DEV__) { console.warn(e); } this.guardedSetState({ selections: null, error: 'something went wrong :(', }); } }; async getPermissions(): Promise { const { granted } = await MediaLibrary.requestPermissionsAsync(); if (!granted) { this.guardedSetState({ error: "don't have permission :(" }); } return granted; } get queueModeActive() { return !!this.state.queuedMediaURIs; } renderItem = (row: { item: MediaLibrarySelection, ... }) => { const { containerHeight, queuedMediaURIs } = this.state; invariant(containerHeight, 'should be set'); const { uri } = row.item; const isQueued = !!(queuedMediaURIs && queuedMediaURIs.has(uri)); const { queueModeActive } = this; return ( ); }; ItemSeparator = () => { return ; }; static keyExtractor = (item: MediaLibrarySelection) => { return item.uri; }; GalleryHeader = () => ( Photos ); render() { let content; const { selections, error, containerHeight } = this.state; const bottomOffsetStyle: ViewStyle = { marginBottom: this.props.dimensions.bottomInset, }; if (selections && selections.length > 0 && containerHeight) { content = ( ); } else if (selections && containerHeight) { content = ( no media was found! ); } else if (error) { content = ( {error} ); } else { content = ( ); } const { queuedMediaURIs } = this.state; const queueCount = queuedMediaURIs ? queuedMediaURIs.size : 0; const bottomInset = Platform.select({ ios: -1 * this.props.dimensions.bottomInset, default: 0, }); const containerStyle = { bottom: bottomInset }; return ( {content} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onContainerLayout = (event: LayoutEvent) => { this.guardedSetState({ containerHeight: event.nativeEvent.layout.height }); }; onEndReached = () => { const { cursor } = this.state; if (cursor !== null) { this.fetchPhotos(cursor); } }; onViewableItemsChanged = (info: ViewableItemsChange) => { const viewableIndices = []; for (const { index } of info.viewableItems) { if (index !== null && index !== undefined) { viewableIndices.push(index); } } this.viewableIndices = viewableIndices; }; setMediaQueued = (selection: MediaLibrarySelection, isQueued: boolean) => { this.setState((prevState: State) => { const prevQueuedMediaURIs = prevState.queuedMediaURIs ? [...prevState.queuedMediaURIs] : []; if (isQueued) { return { queuedMediaURIs: new Set([...prevQueuedMediaURIs, selection.uri]), focusedMediaURI: null, }; } const queuedMediaURIs = prevQueuedMediaURIs.filter( uri => uri !== selection.uri, ); if (queuedMediaURIs.length < prevQueuedMediaURIs.length) { return { queuedMediaURIs: new Set(queuedMediaURIs), focusedMediaURI: null, }; } return null; }); }; setFocus = (selection: MediaLibrarySelection, isFocused: boolean) => { const { uri } = selection; if (isFocused) { this.setState({ focusedMediaURI: uri }); } else if (this.state.focusedMediaURI === uri) { this.setState({ focusedMediaURI: null }); } }; sendSingleMedia = (selection: MediaLibrarySelection) => { this.sendMedia([selection]); }; sendQueuedMedia = () => { const { selections, queuedMediaURIs } = this.state; if (!selections || !queuedMediaURIs) { return; } const queuedSelections = []; for (const uri of queuedMediaURIs) { for (const selection of selections) { if (selection.uri === uri) { queuedSelections.push(selection); break; } } } this.sendMedia(queuedSelections); }; sendMedia(selections: $ReadOnlyArray) { if (this.mediaSelected) { return; } this.mediaSelected = true; const now = Date.now(); const timeProps = { selectTime: now, sendTime: now, }; const selectionsWithTime = selections.map(selection => ({ ...selection, ...timeProps, })); - KeyboardRegistry.onItemSelected( - mediaGalleryKeyboardName, - selectionsWithTime, - ); + KeyboardRegistry.onItemSelected(mediaGalleryKeyboardName, { + selections: selectionsWithTime, + threadInfo: this.props.threadInfo, + }); } } const mediaGalleryKeyboardName = 'MediaGalleryKeyboard'; const unboundStyles = { container: { backgroundColor: 'listBackground', position: 'absolute', left: 0, right: 0, top: 0, }, galleryHeader: { height: 56, borderTopWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, }, galleryHeaderTitle: { color: 'modalForegroundLabel', fontSize: 14, fontWeight: '500', }, nativePickerButton: { backgroundColor: 'rgba(255, 255, 255, 0.08)', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, }, nativePickerButtonLabel: { color: 'modalButtonLabel', fontSize: 12, fontWeight: '500', }, galleryContainer: { flex: 1, alignItems: 'center', flexDirection: 'row', }, error: { color: 'listBackgroundLabel', flex: 1, fontSize: 28, textAlign: 'center', }, loadingIndicator: { flex: 1, }, sendButtonContainer: { bottom: 20, position: 'absolute', right: 30, }, separator: { width: 2, }, }; -function ConnectedMediaGalleryKeyboard() { +function ConnectedMediaGalleryKeyboard(props: BaseProps) { const dimensions = useSelector(state => state.dimensions); const foreground = useIsAppForegrounded(); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); } -function ReduxMediaGalleryKeyboard() { +function ReduxMediaGalleryKeyboard(props: BaseProps) { return ( - + ); } KeyboardRegistry.registerKeyboard( mediaGalleryKeyboardName, () => ReduxMediaGalleryKeyboard, ); export { mediaGalleryKeyboardName };