Skip to content

Commit

Permalink
ComposeBox: Allow showing a thumbnail preview of images before sending.
Browse files Browse the repository at this point in the history
This commit makes the necessary changes to ComposeMenu and ComposeBox
in order to select and upload images per message while composing while
showing a thumbnail preview. It also allows you to delete a selected
image before sending the message and choose another one.

The code in this commit is written to handle multiple images but the
max limit is currently set to 1. Can enable more in the future.

It also allows you to select a topic for an image.
  • Loading branch information
armaanahluwalia committed Dec 8, 2018
1 parent 1cf8d44 commit 4e57737
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 56 deletions.
121 changes: 110 additions & 11 deletions src/compose/ComposeBox.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* @flow */
import React, { PureComponent } from 'react';
import { View, TextInput, findNodeHandle } from 'react-native';
import { View, TextInput, findNodeHandle, Image, FlatList } from 'react-native';
import { connect } from 'react-redux';
import TextInputReset from 'react-native-text-input-reset';

Expand All @@ -14,20 +14,23 @@ import type {
Dispatch,
Dimensions,
GlobalState,
DraftImagesState,
} from '../types';
import {
addToOutbox,
cancelEditMessage,
draftUpdate,
draftImageAdd,
draftImageRemove,
fetchTopicsForActiveStream,
sendTypingEvent,
} from '../actions';
import { updateMessage } from '../api';
import { updateMessage, uploadFile } from '../api';
import { FloatingActionButton, Input, MultilineInput } from '../common';
import { showErrorAlert } from '../utils/info';
import { IconDone, IconSend } from '../common/Icons';
import { IconDone, IconSend, IconCross } from '../common/Icons';
import { isStreamNarrow, isStreamOrTopicNarrow, topicNarrow } from '../utils/narrow';
import ComposeMenu from './ComposeMenu';
import ComposeMenu, { handleImagePickerError } from './ComposeMenu';
import AutocompleteViewWrapper from '../autocomplete/AutocompleteViewWrapper';
import getComposeInputPlaceholder from './getComposeInputPlaceholder';
import NotSubscribed from '../message/NotSubscribed';
Expand All @@ -47,13 +50,15 @@ import {
getIsActiveStreamAnnouncementOnly,
} from '../subscriptions/subscriptionSelectors';
import { getDraftForActiveNarrow } from '../drafts/draftsSelectors';
import { getDraftImageData } from '../draftImages/draftImagesSelectors';

type Props = {
auth: Auth,
canSend: boolean,
narrow: Narrow,
users: User[],
draft: string,
draftImages: DraftImagesState,
lastMessageTopic: string,
isAdmin: boolean,
isAnnouncementOnly: boolean,
Expand Down Expand Up @@ -113,14 +118,15 @@ class ComposeBox extends PureComponent<Props, State> {

getCanSelectTopic = () => {
const { isMessageFocused, isTopicFocused } = this.state;
const { editMessage, narrow } = this.props;
const { editMessage, narrow, draftImages } = this.props;
if (editMessage) {
return isStreamOrTopicNarrow(narrow);
}
if (!isStreamNarrow(narrow)) {
return false;
}
return isMessageFocused || isTopicFocused;
const hasImages = Boolean(Object.keys(draftImages).length);
return isMessageFocused || isTopicFocused || hasImages;
};

setMessageInputValue = (message: string) => {
Expand All @@ -139,6 +145,23 @@ class ComposeBox extends PureComponent<Props, State> {
}));
};

handleImageSelect = (imageEventObj: Object) => {
const { dispatch, response } = imageEventObj;
if (!response.images || !response.images.length) {
return;
}
const newTopic = this.state.topic || this.props.lastMessageTopic;
response.images.forEach(image => {
dispatch(draftImageAdd(image.uri, image.fileName, image.uri));
});
setTimeout(() => {
this.setTopicInputValue(newTopic);
}, 200); // wait, to hope the component is shown
};
handleRemoveDraftImage = (id: string) => {
const { dispatch } = this.props;
dispatch(draftImageRemove(id));
};
handleLayoutChange = (event: Object) => {
this.setState({
height: event.nativeEvent.layout.height,
Expand Down Expand Up @@ -218,13 +241,54 @@ class ComposeBox extends PureComponent<Props, State> {
return isStreamNarrow(narrow) ? topicNarrow(narrow[0].operand, topic || '(no topic)') : narrow;
};

uploadAllDrafts = () => {
const { dispatch, draftImages, auth } = this.props;
const messageUriArr = [];
const imageIds = Object.keys(draftImages);
imageIds.forEach(id => {
const imageObj = draftImages[id];
const uriPromise = new Promise(async (resolve, reject) => {
try {
const remoteUri = await uploadFile(auth, imageObj.uri, imageObj.fileName);
resolve(`[${imageObj.fileName}](${remoteUri})`);
dispatch(draftImageRemove(id));
} catch (e) {
reject(e);
}
});
messageUriArr.push(uriPromise);
});
return Promise.all(messageUriArr);
};

getFormattedMessage = async (message?: string): Promise<any> => {
let draftImages = [];
message = message != null ? message : '';
try {
draftImages = await this.uploadAllDrafts();
} catch (e) {
throw e;
}
if (!draftImages.length) {
return message;
}
return `${message}\n${draftImages.join('\n')}`;
};

handleSend = () => {
const { dispatch } = this.props;
const { message } = this.state;

dispatch(addToOutbox(this.getDestinationNarrow(), message));

this.setMessageInputValue('');
this.getFormattedMessage(message)
.then(formattedMsg => {
if (formattedMsg.length) {
dispatch(addToOutbox(this.getDestinationNarrow(), formattedMsg));
}
this.setMessageInputValue('');
})
.catch(e => {
showErrorAlert('Error', e.toString());
});
};

handleEdit = () => {
Expand Down Expand Up @@ -275,8 +339,11 @@ class ComposeBox extends PureComponent<Props, State> {
isAdmin,
isAnnouncementOnly,
isSubscribed,
draftImages,
} = this.props;

const { handleRemoveDraftImage } = this;

if (!isSubscribed) {
return <NotSubscribed narrow={narrow} />;
} else if (isAnnouncementOnly && !isAdmin) {
Expand All @@ -288,7 +355,28 @@ class ComposeBox extends PureComponent<Props, State> {
marginBottom: safeAreaInsets.bottom,
...(canSend ? {} : { opacity: 0, position: 'absolute' }),
};

const renderImagePreview = ({ item }) => {
const { key } = item;
return (
<View style={styles.composeImageContainer} key={key}>
<FloatingActionButton
style={styles.composeImageDeleteButton}
Icon={IconCross}
size={25}
disabled={false}
imageId={key}
onPress={() => handleRemoveDraftImage(key)}
/>
<Image
style={styles.composeImage}
resizeMode="cover"
source={{ isStatic: true, uri: draftImages[key].uri }}
/>
</View>
);
};
const imagePreviewData = Object.keys(draftImages).map(id => ({ key: id }));
const numberOfDraftImages = Object.keys(draftImages).length;
return (
<View style={style}>
<AutocompleteViewWrapper
Expand All @@ -307,6 +395,10 @@ class ComposeBox extends PureComponent<Props, State> {
destinationNarrow={this.getDestinationNarrow()}
expanded={isMenuExpanded}
onExpandContract={this.handleComposeMenuToggle}
onImageSelect={this.handleImageSelect}
onImageError={handleImagePickerError}
disableCamera={numberOfDraftImages >= 1}
disableUpload={numberOfDraftImages >= 1}
/>
</View>
<View style={styles.composeText}>
Expand All @@ -325,6 +417,12 @@ class ComposeBox extends PureComponent<Props, State> {
onTouchStart={this.handleInputTouchStart}
/>
)}
<FlatList
data={imagePreviewData}
contentContainerStyle={styles.composeImages}
numColumns={2}
renderItem={renderImagePreview}
/>
<MultilineInput
style={styles.composeTextInput}
placeholder={placeholder}
Expand All @@ -345,7 +443,7 @@ class ComposeBox extends PureComponent<Props, State> {
style={styles.composeSendButton}
Icon={editMessage === null ? IconSend : IconDone}
size={32}
disabled={message.trim().length === 0}
disabled={message.trim().length === 0 && numberOfDraftImages === 0}
onPress={editMessage === null ? this.handleSend : this.handleEdit}
/>
</View>
Expand All @@ -365,5 +463,6 @@ export default connect((state: GlobalState, props) => ({
canSend: canSendToActiveNarrow(props.narrow) && !getShowMessagePlaceholders(props.narrow)(state),
editMessage: getSession(state).editMessage,
draft: getDraftForActiveNarrow(props.narrow)(state),
draftImages: getDraftImageData(state),
lastMessageTopic: getLastMessageTopic(props.narrow)(state),
}))(ComposeBox);
111 changes: 77 additions & 34 deletions src/compose/ComposeMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import type { Context, Dispatch, Narrow } from '../types';
import { showErrorAlert } from '../utils/info';
import { IconPlus, IconLeft, IconPeople, IconImage, IconCamera } from '../common/Icons';
import AnimatedComponent from '../animation/AnimatedComponent';
import { navigateToCreateGroup, uploadImage } from '../actions';
import { navigateToCreateGroup } from '../actions';

type Props = {
dispatch: Dispatch,
expanded: boolean,
destinationNarrow: Narrow,
onExpandContract: () => void,
onImageSelect: Object => void,
onImageError: Object => void,
disableUpload?: boolean,
disableCamera?: boolean,
};

/*
Expand Down Expand Up @@ -53,6 +57,13 @@ export const chooseUploadImageFilename = (uri: string, fileName?: string): strin
return fileName;
};

export const handleImagePickerError = (e: Object) => {
if (e.code === 'E_PICKER_CANCELLED') {
return;
}
showErrorAlert(e.toString(), 'Error');
};

class ComposeMenu extends PureComponent<Props> {
context: Context;
props: Props;
Expand All @@ -62,31 +73,47 @@ class ComposeMenu extends PureComponent<Props> {
};

handleImageRequest = async (requestType: 'openPicker' | 'openCamera') => {
let image;
const { dispatch, destinationNarrow } = this.props;
const { dispatch, destinationNarrow, onImageSelect, onImageError } = this.props;
const defaults = {
mediaType: 'photo',
compressImageMaxWidth: 2000,
compressImageMaxHeight: 2000,
forceJpg: true,
compressImageQuality: 0.7,
};
let response;
let requestObj = {
...defaults,
};

if (requestType === 'openPicker') {
requestObj = {
...defaults,
// multiple: true,
maxFiles: 1,
};
}
try {
image = await ImagePicker[requestType]({
mediaType: 'photo',
compressImageMaxWidth: 2000,
compressImageMaxHeight: 2000,
forceJpg: true,
compressImageQuality: 0.7,
});
let images = await ImagePicker[requestType](requestObj);
images = Array.isArray(images) ? images : [images];
response = {
images: images.map(image => {
const inferredFileName = chooseUploadImageFilename(image.path, image.filename);
return {
uri: image.path,
fileName: inferredFileName,
};
}),
};
} catch (e) {
if (e.code === 'E_PICKER_CANCELLED') {
return;
}
showErrorAlert(e.toString(), 'Error');
onImageError(e);
return;
}

dispatch(
uploadImage(
destinationNarrow,
image.path,
chooseUploadImageFilename(image.path, image.filename),
),
);
onImageSelect({
destinationNarrow,
response,
dispatch,
});
};
handleImageUpload = () => {
this.handleImageRequest('openPicker');
Expand All @@ -98,26 +125,42 @@ class ComposeMenu extends PureComponent<Props> {

render() {
const { styles } = this.context;
const { dispatch, expanded, onExpandContract } = this.props;
const { dispatch, expanded, onExpandContract, disableUpload, disableCamera } = this.props;
let animatedWidth = 40;
if (!disableCamera) {
animatedWidth += 40;
}
if (!disableUpload) {
animatedWidth += 40;
}
return (
<View style={styles.composeMenu}>
<AnimatedComponent property="width" useNativeDriver={false} visible={expanded} width={120}>
<AnimatedComponent
property="width"
useNativeDriver={false}
visible={expanded}
width={animatedWidth}
>
<View style={styles.composeMenu}>
<IconPeople
style={styles.composeMenuButton}
size={24}
onPress={() => dispatch(navigateToCreateGroup())}
/>
<IconImage
style={styles.composeMenuButton}
size={24}
onPress={this.handleImageUpload}
/>
<IconCamera
style={styles.composeMenuButton}
size={24}
onPress={this.handleCameraCapture}
/>
{!disableUpload && (
<IconImage
style={styles.composeMenuButton}
size={24}
onPress={this.handleImageUpload}
/>
)}
{!disableCamera && (
<IconCamera
style={styles.composeMenuButton}
size={24}
onPress={this.handleCameraCapture}
/>
)}
</View>
</AnimatedComponent>
{!expanded && <IconPlus style={styles.expandButton} size={24} onPress={onExpandContract} />}
Expand Down
Loading

0 comments on commit 4e57737

Please sign in to comment.