diff --git a/native/chat/inline-multimedia.react.js b/native/chat/inline-multimedia.react.js
index 47013f3ca..909f7573f 100644
--- a/native/chat/inline-multimedia.react.js
+++ b/native/chat/inline-multimedia.react.js
@@ -1,103 +1,139 @@
// @flow
import * as React from 'react';
-import { View, StyleSheet } from 'react-native';
+import { View, StyleSheet, Text } from 'react-native';
import * as Progress from 'react-native-progress';
import Icon from 'react-native-vector-icons/Feather';
+import tinycolor from 'tinycolor2';
import type { MediaInfo } from 'lib/types/media-types';
import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react';
import type { PendingMultimediaUpload } from '../input/input-state';
import { KeyboardContext } from '../keyboard/keyboard-state';
import Multimedia from '../media/multimedia.react';
-const formatProgressText = (progress: number) =>
- `${Math.floor(progress * 100)}%`;
-
type Props = {|
+mediaInfo: MediaInfo,
+onPress: () => void,
+onLongPress: () => void,
+postInProgress: boolean,
+pendingUpload: ?PendingMultimediaUpload,
+spinnerColor: string,
|};
function InlineMultimedia(props: Props) {
const { mediaInfo, pendingUpload, postInProgress } = props;
let failed = mediaInfo.id.startsWith('localUpload') && !postInProgress;
let progressPercent = 1;
+ let processingStep;
if (pendingUpload) {
- ({ progressPercent, failed } = pendingUpload);
+ ({ progressPercent, failed, processingStep } = pendingUpload);
}
let progressIndicator;
if (failed) {
progressIndicator = (
);
} else if (progressPercent !== 1) {
+ const progressOverlay = (
+
+
+ {`${Math.floor(progressPercent * 100).toString()}%`}
+
+
+ {processingStep ? processingStep : 'pending'}
+
+
+ );
+
+ const primaryColor = tinycolor(props.spinnerColor);
+ const secondaryColor = primaryColor.isDark()
+ ? primaryColor.lighten(20).toString()
+ : primaryColor.darken(20).toString();
+
+ const progressSpinnerProps = {
+ size: 120,
+ indeterminate: progressPercent === 0,
+ progress: progressPercent,
+ fill: secondaryColor,
+ unfilledColor: secondaryColor,
+ color: props.spinnerColor,
+ thickness: 10,
+ borderWidth: 0,
+ };
+
+ let progressSpinner;
+ if (processingStep === 'transcoding') {
+ progressSpinner = ;
+ } else {
+ progressSpinner = ;
+ }
+
progressIndicator = (
-
+ {progressSpinner}
+ {progressOverlay}
);
}
const keyboardState = React.useContext(KeyboardContext);
const keyboardShowing = keyboardState?.keyboardShowing;
return (
{progressIndicator}
);
}
const styles = StyleSheet.create({
centerContainer: {
alignItems: 'center',
bottom: 0,
justifyContent: 'center',
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
expand: {
flex: 1,
},
- progressIndicatorText: {
- color: 'black',
- fontSize: 21,
+ processingStepText: {
+ color: 'white',
+ fontSize: 12,
+ textShadowColor: '#000',
+ textShadowRadius: 1,
+ },
+ progressOverlay: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ position: 'absolute',
+ },
+ progressPercentText: {
+ color: 'white',
+ fontSize: 24,
+ fontWeight: 'bold',
+ textShadowColor: '#000',
+ textShadowRadius: 1,
},
uploadError: {
color: 'white',
textShadowColor: '#000',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 1,
},
});
export default InlineMultimedia;
diff --git a/native/chat/multimedia-message-multimedia.react.js b/native/chat/multimedia-message-multimedia.react.js
index e5fa66c2e..9917ce236 100644
--- a/native/chat/multimedia-message-multimedia.react.js
+++ b/native/chat/multimedia-message-multimedia.react.js
@@ -1,346 +1,346 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { View, StyleSheet } from 'react-native';
import Animated from 'react-native-reanimated';
import { useSelector } from 'react-redux';
import { messageKey } from 'lib/shared/message-utils';
import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils';
import { threadHasPermission } from 'lib/shared/thread-utils';
import { type MediaInfo } from 'lib/types/media-types';
import { threadPermissions } from 'lib/types/thread-types';
import type { UserInfo } from 'lib/types/user-types';
import { type PendingMultimediaUpload } from '../input/input-state';
import {
type KeyboardState,
KeyboardContext,
} from '../keyboard/keyboard-state';
import {
OverlayContext,
type OverlayContextType,
} from '../navigation/overlay-context';
import type { NavigationRoute } from '../navigation/route-names';
import {
MultimediaModalRouteName,
MultimediaTooltipModalRouteName,
} from '../navigation/route-names';
import { type Colors, useColors } from '../themes/colors';
import { type VerticalBounds } from '../types/layout-types';
import type { ViewStyle } from '../types/styles';
import type { ChatNavigationProp } from './chat.react';
import InlineMultimedia from './inline-multimedia.react';
import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react';
import { multimediaTooltipHeight } from './multimedia-tooltip-modal.react';
/* eslint-disable import/no-named-as-default-member */
const { Value, sub, interpolate, Extrapolate } = Animated;
/* eslint-enable import/no-named-as-default-member */
type BaseProps = {|
+mediaInfo: MediaInfo,
+item: ChatMultimediaMessageInfoItem,
+navigation: ChatNavigationProp<'MessageList'>,
+route: NavigationRoute<'MessageList'>,
+verticalBounds: ?VerticalBounds,
+verticalOffset: number,
+style: ViewStyle,
+postInProgress: boolean,
+pendingUpload: ?PendingMultimediaUpload,
+messageFocused: boolean,
+toggleMessageFocus: (messageKey: string) => void,
|};
type Props = {|
...BaseProps,
// Redux state
+colors: Colors,
+messageCreatorUserInfo: UserInfo,
// withKeyboardState
+keyboardState: ?KeyboardState,
// withOverlayContext
+overlayContext: ?OverlayContextType,
|};
type State = {|
+opacity: number | Value,
|};
class MultimediaMessageMultimedia extends React.PureComponent {
view: ?React.ElementRef;
clickable = true;
constructor(props: Props) {
super(props);
this.state = {
opacity: this.getOpacity(),
};
}
static getStableKey(props: Props) {
const { item, mediaInfo } = props;
return `multimedia|${messageKey(item.messageInfo)}|${mediaInfo.index}`;
}
static getOverlayContext(props: Props) {
const { overlayContext } = props;
invariant(
overlayContext,
'MultimediaMessageMultimedia should have OverlayContext',
);
return overlayContext;
}
static getModalOverlayPosition(props: Props) {
const overlayContext = MultimediaMessageMultimedia.getOverlayContext(props);
const { visibleOverlays } = overlayContext;
for (let overlay of visibleOverlays) {
if (
overlay.routeName === MultimediaModalRouteName &&
overlay.presentedFrom === props.route.key &&
overlay.routeKey === MultimediaMessageMultimedia.getStableKey(props)
) {
return overlay.position;
}
}
return undefined;
}
getOpacity() {
const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition(
this.props,
);
if (!overlayPosition) {
return 1;
}
return sub(
1,
interpolate(overlayPosition, {
inputRange: [0.1, 0.11],
outputRange: [0, 1],
extrapolate: Extrapolate.CLAMP,
}),
);
}
componentDidUpdate(prevProps: Props) {
const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition(
this.props,
);
const prevOverlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition(
prevProps,
);
if (overlayPosition !== prevOverlayPosition) {
this.setState({ opacity: this.getOpacity() });
}
const scrollIsDisabled =
MultimediaMessageMultimedia.getOverlayContext(this.props)
.scrollBlockingModalStatus !== 'closed';
const scrollWasDisabled =
MultimediaMessageMultimedia.getOverlayContext(prevProps)
.scrollBlockingModalStatus !== 'closed';
if (!scrollIsDisabled && scrollWasDisabled) {
this.clickable = true;
}
}
render() {
const { opacity } = this.state;
const wrapperStyles = [styles.container, { opacity }, this.props.style];
const { mediaInfo, pendingUpload, postInProgress } = this.props;
return (
);
}
onLayout = () => {};
viewRef = (view: ?React.ElementRef) => {
this.view = view;
};
onPress = () => {
if (this.dismissKeyboardIfShowing()) {
return;
}
const {
view,
props: { verticalBounds },
} = this;
if (!view || !verticalBounds) {
return;
}
if (!this.clickable) {
return;
}
this.clickable = false;
const overlayContext = MultimediaMessageMultimedia.getOverlayContext(
this.props,
);
overlayContext.setScrollBlockingModalStatus('open');
const { mediaInfo, item } = this.props;
view.measure((x, y, width, height, pageX, pageY) => {
const coordinates = { x: pageX, y: pageY, width, height };
this.props.navigation.navigate({
name: MultimediaModalRouteName,
key: MultimediaMessageMultimedia.getStableKey(this.props),
params: {
presentedFrom: this.props.route.key,
mediaInfo,
item,
initialCoordinates: coordinates,
verticalBounds,
},
});
});
};
visibleEntryIDs() {
const result = ['save'];
const canCreateSidebars = threadHasPermission(
this.props.item.threadInfo,
threadPermissions.CREATE_SIDEBARS,
);
const creatorRelationship = this.props.messageCreatorUserInfo
.relationshipStatus;
const creatorRelationshipHasBlock =
creatorRelationship &&
relationshipBlockedInEitherDirection(creatorRelationship);
if (this.props.item.threadCreatedFromMessage) {
result.push('open_sidebar');
} else if (canCreateSidebars && !creatorRelationshipHasBlock) {
result.push('create_sidebar');
}
return result;
}
onLongPress = () => {
if (this.dismissKeyboardIfShowing()) {
return;
}
const {
view,
props: { verticalBounds },
} = this;
if (!view || !verticalBounds) {
return;
}
if (!this.clickable) {
return;
}
this.clickable = false;
const {
messageFocused,
toggleMessageFocus,
item,
mediaInfo,
verticalOffset,
} = this.props;
if (!messageFocused) {
toggleMessageFocus(messageKey(item.messageInfo));
}
const overlayContext = MultimediaMessageMultimedia.getOverlayContext(
this.props,
);
overlayContext.setScrollBlockingModalStatus('open');
view.measure((x, y, width, height, pageX, pageY) => {
const coordinates = { x: pageX, y: pageY, width, height };
const multimediaTop = pageY;
const multimediaBottom = pageY + height;
const boundsTop = verticalBounds.y;
const boundsBottom = verticalBounds.y + verticalBounds.height;
const belowMargin = 20;
const belowSpace = multimediaTooltipHeight + belowMargin;
const { isViewer } = item.messageInfo.creator;
const directlyAboveMargin = isViewer ? 30 : 50;
const aboveMargin = verticalOffset === 0 ? directlyAboveMargin : 20;
const aboveSpace = multimediaTooltipHeight + aboveMargin;
let location = 'below',
margin = belowMargin;
if (
multimediaBottom + belowSpace > boundsBottom &&
multimediaTop - aboveSpace > boundsTop
) {
location = 'above';
margin = aboveMargin;
}
this.props.navigation.navigate({
name: MultimediaTooltipModalRouteName,
params: {
presentedFrom: this.props.route.key,
mediaInfo,
item,
initialCoordinates: coordinates,
verticalOffset,
verticalBounds,
location,
margin,
visibleEntryIDs: this.visibleEntryIDs(),
},
});
});
};
dismissKeyboardIfShowing = () => {
const { keyboardState } = this.props;
return !!(keyboardState && keyboardState.dismissKeyboardIfShowing());
};
}
const styles = StyleSheet.create({
container: {
flex: 1,
overflow: 'hidden',
},
expand: {
flex: 1,
},
});
export default React.memo(
function ConnectedMultimediaMessageMultimedia(props: BaseProps) {
const colors = useColors();
const keyboardState = React.useContext(KeyboardContext);
const overlayContext = React.useContext(OverlayContext);
const messageCreatorUserInfo = useSelector(
(state) => state.userStore.userInfos[props.item.messageInfo.creator.id],
);
return (
);
},
);
diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js
index 7a3321b83..a437399f7 100644
--- a/native/input/input-state-container.react.js
+++ b/native/input/input-state-container.react.js
@@ -1,1099 +1,1099 @@
// @flow
import invariant from 'invariant';
import PropTypes from 'prop-types';
import * as React from 'react';
import { Platform } from 'react-native';
import * as Upload from 'react-native-background-upload';
import { createSelector } from 'reselect';
import {
createLocalMessageActionType,
sendMultimediaMessageActionTypes,
sendMultimediaMessage,
sendTextMessageActionTypes,
sendTextMessage,
} from 'lib/actions/message-actions';
import { queueReportsActionType } from 'lib/actions/report-actions';
import {
uploadMultimedia,
updateMultimediaMessageMediaActionType,
type MultimediaUploadCallbacks,
type MultimediaUploadExtras,
} from 'lib/actions/upload-actions';
import { pathFromURI } from 'lib/media/file-utils';
import { videoDurationLimit } from 'lib/media/video-utils';
import {
createLoadingStatusSelector,
combineLoadingStatuses,
} from 'lib/selectors/loading-selectors';
import { createMediaMessageInfo } from 'lib/shared/message-utils';
import { isStaff } from 'lib/shared/user-utils';
import type {
UploadMultimediaResult,
Media,
NativeMediaSelection,
MediaMissionResult,
MediaMission,
} from 'lib/types/media-types';
import {
messageTypes,
type RawMessageInfo,
type RawMultimediaMessageInfo,
type SendMessageResult,
type SendMessagePayload,
} from 'lib/types/message-types';
import type { RawImagesMessageInfo } from 'lib/types/messages/images';
import type { RawMediaMessageInfo } from 'lib/types/messages/media';
import type { RawTextMessageInfo } from 'lib/types/messages/text';
import {
type MediaMissionReportCreationRequest,
reportTypes,
} from 'lib/types/report-types';
import type {
DispatchActionPayload,
DispatchActionPromise,
} from 'lib/utils/action-utils';
import { getConfig } from 'lib/utils/config';
import { getMessageForException, cloneError } from 'lib/utils/errors';
import type {
FetchJSONOptions,
FetchJSONServerResponse,
} from 'lib/utils/fetch-json';
import { connect } from 'lib/utils/redux-utils';
import { disposeTempFile } from '../media/file-utils';
import { processMedia } from '../media/media-utils';
import { displayActionResultModal } from '../navigation/action-result-modal';
import type { AppState } from '../redux/redux-setup';
import {
InputStateContext,
type PendingMultimediaUploads,
type MultimediaProcessingStep,
} from './input-state';
let nextLocalUploadID = 0;
function getNewLocalID() {
return `localUpload${nextLocalUploadID++}`;
}
type SelectionWithID = {|
selection: NativeMediaSelection,
localID: string,
|};
type CompletedUploads = { [localMessageID: string]: ?Set };
type Props = {|
children: React.Node,
// Redux state
viewerID: ?string,
nextLocalID: number,
messageStoreMessages: { [id: string]: RawMessageInfo },
ongoingMessageCreation: boolean,
hasWiFi: boolean,
// Redux dispatch functions
dispatchActionPayload: DispatchActionPayload,
dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
uploadMultimedia: (
multimedia: Object,
extras: MultimediaUploadExtras,
callbacks: MultimediaUploadCallbacks,
) => Promise,
sendMultimediaMessage: (
threadID: string,
localID: string,
mediaIDs: $ReadOnlyArray,
) => Promise,
sendTextMessage: (
threadID: string,
localID: string,
text: string,
) => Promise,
|};
type State = {|
pendingUploads: PendingMultimediaUploads,
|};
class InputStateContainer extends React.PureComponent {
static propTypes = {
children: PropTypes.node.isRequired,
viewerID: PropTypes.string,
nextLocalID: PropTypes.number.isRequired,
messageStoreMessages: PropTypes.object.isRequired,
ongoingMessageCreation: PropTypes.bool.isRequired,
hasWiFi: PropTypes.bool.isRequired,
dispatchActionPayload: PropTypes.func.isRequired,
dispatchActionPromise: PropTypes.func.isRequired,
uploadMultimedia: PropTypes.func.isRequired,
sendMultimediaMessage: PropTypes.func.isRequired,
sendTextMessage: PropTypes.func.isRequired,
};
state: State = {
pendingUploads: {},
};
sendCallbacks: Array<() => void> = [];
activeURIs = new Map();
replyCallbacks: Array<(message: string) => void> = [];
static getCompletedUploads(props: Props, state: State): CompletedUploads {
const completedUploads = {};
for (let localMessageID in state.pendingUploads) {
const messagePendingUploads = state.pendingUploads[localMessageID];
const rawMessageInfo = props.messageStoreMessages[localMessageID];
if (!rawMessageInfo) {
continue;
}
invariant(
rawMessageInfo.type === messageTypes.IMAGES ||
rawMessageInfo.type === messageTypes.MULTIMEDIA,
`rawMessageInfo ${localMessageID} should be multimedia`,
);
const completed = [];
let allUploadsComplete = true;
for (let localUploadID in messagePendingUploads) {
let media;
for (let singleMedia of rawMessageInfo.media) {
if (singleMedia.id === localUploadID) {
media = singleMedia;
break;
}
}
if (media) {
allUploadsComplete = false;
} else {
completed.push(localUploadID);
}
}
if (allUploadsComplete) {
completedUploads[localMessageID] = null;
} else if (completed.length > 0) {
completedUploads[localMessageID] = new Set(completed);
}
}
return completedUploads;
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.props.viewerID !== prevProps.viewerID) {
this.setState({ pendingUploads: {} });
return;
}
const currentlyComplete = InputStateContainer.getCompletedUploads(
this.props,
this.state,
);
const previouslyComplete = InputStateContainer.getCompletedUploads(
prevProps,
prevState,
);
const newPendingUploads = {};
let pendingUploadsChanged = false;
const readyMessageIDs = [];
for (let localMessageID in this.state.pendingUploads) {
const messagePendingUploads = this.state.pendingUploads[localMessageID];
const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID];
const rawMessageInfo = this.props.messageStoreMessages[localMessageID];
const completedUploadIDs = currentlyComplete[localMessageID];
const previouslyCompletedUploadIDs = previouslyComplete[localMessageID];
if (!rawMessageInfo && prevRawMessageInfo) {
pendingUploadsChanged = true;
continue;
} else if (completedUploadIDs === null) {
// All of this message's uploads have been completed
newPendingUploads[localMessageID] = {};
if (previouslyCompletedUploadIDs !== null) {
readyMessageIDs.push(localMessageID);
pendingUploadsChanged = true;
}
continue;
} else if (!completedUploadIDs) {
// Nothing has been completed
newPendingUploads[localMessageID] = messagePendingUploads;
continue;
}
const newUploads = {};
let uploadsChanged = false;
for (let localUploadID in messagePendingUploads) {
if (!completedUploadIDs.has(localUploadID)) {
newUploads[localUploadID] = messagePendingUploads[localUploadID];
} else if (
!previouslyCompletedUploadIDs ||
!previouslyCompletedUploadIDs.has(localUploadID)
) {
uploadsChanged = true;
}
}
if (uploadsChanged) {
pendingUploadsChanged = true;
newPendingUploads[localMessageID] = newUploads;
} else {
newPendingUploads[localMessageID] = messagePendingUploads;
}
}
if (pendingUploadsChanged) {
this.setState({ pendingUploads: newPendingUploads });
}
for (let localMessageID of readyMessageIDs) {
const rawMessageInfo = this.props.messageStoreMessages[localMessageID];
if (!rawMessageInfo) {
continue;
}
invariant(
rawMessageInfo.type === messageTypes.IMAGES ||
rawMessageInfo.type === messageTypes.MULTIMEDIA,
`rawMessageInfo ${localMessageID} should be multimedia`,
);
this.dispatchMultimediaMessageAction(rawMessageInfo);
}
}
dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) {
this.props.dispatchActionPromise(
sendMultimediaMessageActionTypes,
this.sendMultimediaMessageAction(messageInfo),
undefined,
messageInfo,
);
}
async sendMultimediaMessageAction(
messageInfo: RawMultimediaMessageInfo,
): Promise {
const { localID, threadID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should be set',
);
const mediaIDs = [];
for (let { id } of messageInfo.media) {
mediaIDs.push(id);
}
try {
const result = await this.props.sendMultimediaMessage(
threadID,
localID,
mediaIDs,
);
return {
localID,
serverID: result.id,
threadID,
time: result.time,
interface: result.interface,
};
} catch (e) {
const copy = cloneError(e);
copy.localID = localID;
copy.threadID = threadID;
throw copy;
}
}
inputStateSelector = createSelector(
(state: State) => state.pendingUploads,
(pendingUploads: PendingMultimediaUploads) => ({
pendingUploads,
sendTextMessage: this.sendTextMessage,
sendMultimediaMessage: this.sendMultimediaMessage,
addReply: this.addReply,
addReplyListener: this.addReplyListener,
removeReplyListener: this.removeReplyListener,
messageHasUploadFailure: this.messageHasUploadFailure,
retryMultimediaMessage: this.retryMultimediaMessage,
registerSendCallback: this.registerSendCallback,
unregisterSendCallback: this.unregisterSendCallback,
uploadInProgress: this.uploadInProgress,
reportURIDisplayed: this.reportURIDisplayed,
}),
);
uploadInProgress = () => {
if (this.props.ongoingMessageCreation) {
return true;
}
for (let localMessageID in this.state.pendingUploads) {
const messagePendingUploads = this.state.pendingUploads[localMessageID];
for (let localUploadID in messagePendingUploads) {
const { failed } = messagePendingUploads[localUploadID];
if (!failed) {
return true;
}
}
}
return false;
};
sendTextMessage = (messageInfo: RawTextMessageInfo) => {
this.sendCallbacks.forEach((callback) => callback());
this.props.dispatchActionPromise(
sendTextMessageActionTypes,
this.sendTextMessageAction(messageInfo),
undefined,
messageInfo,
);
};
async sendTextMessageAction(
messageInfo: RawTextMessageInfo,
): Promise {
try {
const { localID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should be set',
);
const result = await this.props.sendTextMessage(
messageInfo.threadID,
localID,
messageInfo.text,
);
return {
localID,
serverID: result.id,
threadID: messageInfo.threadID,
time: result.time,
interface: result.interface,
};
} catch (e) {
const copy = cloneError(e);
copy.localID = messageInfo.localID;
copy.threadID = messageInfo.threadID;
throw copy;
}
}
sendMultimediaMessage = async (
threadID: string,
selections: $ReadOnlyArray,
) => {
this.sendCallbacks.forEach((callback) => callback());
const localMessageID = `local${this.props.nextLocalID}`;
const selectionsWithIDs = selections.map((selection) => ({
selection,
localID: getNewLocalID(),
}));
const pendingUploads = {};
for (let { localID } of selectionsWithIDs) {
pendingUploads[localID] = {
failed: null,
progressPercent: 0,
};
}
this.setState(
(prevState) => {
return {
pendingUploads: {
...prevState.pendingUploads,
[localMessageID]: pendingUploads,
},
};
},
() => {
const creatorID = this.props.viewerID;
invariant(creatorID, 'need viewer ID in order to send a message');
const media = selectionsWithIDs.map(({ localID, selection }) => {
if (selection.step === 'photo_library') {
return {
id: localID,
uri: selection.uri,
type: 'photo',
dimensions: selection.dimensions,
localMediaSelection: selection,
};
} else if (selection.step === 'photo_capture') {
return {
id: localID,
uri: selection.uri,
type: 'photo',
dimensions: selection.dimensions,
localMediaSelection: selection,
};
} else if (selection.step === 'photo_paste') {
return {
id: localID,
uri: selection.uri,
type: 'photo',
dimensions: selection.dimensions,
localMediaSelection: selection,
};
} else if (selection.step === 'video_library') {
return {
id: localID,
uri: selection.uri,
type: 'video',
dimensions: selection.dimensions,
localMediaSelection: selection,
loop: false,
};
}
invariant(false, `invalid selection ${JSON.stringify(selection)}`);
});
const messageInfo = createMediaMessageInfo({
localID: localMessageID,
threadID,
creatorID,
media,
});
this.props.dispatchActionPayload(
createLocalMessageActionType,
messageInfo,
);
},
);
await this.uploadFiles(localMessageID, selectionsWithIDs);
};
async uploadFiles(
localMessageID: string,
selectionsWithIDs: $ReadOnlyArray,
) {
const results = await Promise.all(
selectionsWithIDs.map((selectionWithID) =>
this.uploadFile(localMessageID, selectionWithID),
),
);
const errors = [...new Set(results.filter(Boolean))];
if (errors.length > 0) {
displayActionResultModal(errors.join(', ') + ' :(');
}
}
async uploadFile(
localMessageID: string,
selectionWithID: SelectionWithID,
): Promise {
const { localID, selection } = selectionWithID;
const start = selection.sendTime;
let steps = [selection],
serverID,
userTime,
errorMessage;
let reportPromise;
const finish = async (result: MediaMissionResult) => {
if (reportPromise) {
const finalSteps = await reportPromise;
steps.push(...finalSteps);
}
const totalTime = Date.now() - start;
userTime = userTime ? userTime : totalTime;
this.queueMediaMissionReport(
{ localID, localMessageID, serverID },
{ steps, result, totalTime, userTime },
);
return errorMessage;
};
const fail = (message: string) => {
errorMessage = message;
this.handleUploadFailure(localMessageID, localID, message);
userTime = Date.now() - start;
};
let processedMedia;
const processingStart = Date.now();
try {
const processMediaReturn = processMedia(
selection,
this.mediaProcessConfig(localMessageID, localID),
);
reportPromise = processMediaReturn.reportPromise;
const processResult = await processMediaReturn.resultPromise;
if (!processResult.success) {
const message =
processResult.reason === 'video_too_long'
? `can't do vids longer than ${videoDurationLimit}min`
: 'processing failed';
fail(message);
return await finish(processResult);
}
processedMedia = processResult;
} catch (e) {
fail('processing failed');
return await finish({
success: false,
reason: 'processing_exception',
time: Date.now() - processingStart,
exceptionMessage: getMessageForException(e),
});
}
const { uploadURI, shouldDisposePath, filename, mime } = processedMedia;
const { hasWiFi } = this.props;
const uploadStart = Date.now();
let uploadExceptionMessage, uploadResult, mediaMissionResult;
try {
uploadResult = await this.props.uploadMultimedia(
{ uri: uploadURI, name: filename, type: mime },
{ ...processedMedia.dimensions, loop: processedMedia.loop },
{
onProgress: (percent: number) =>
- this.setProgress(localMessageID, localID, 'upload', percent),
+ this.setProgress(localMessageID, localID, 'uploading', percent),
uploadBlob: this.uploadBlob,
},
);
mediaMissionResult = { success: true };
} catch (e) {
uploadExceptionMessage = getMessageForException(e);
fail('upload failed');
mediaMissionResult = {
success: false,
reason: 'http_upload_failed',
exceptionMessage: uploadExceptionMessage,
};
}
if (uploadResult) {
const { id, mediaType, uri, dimensions, loop } = uploadResult;
serverID = id;
this.props.dispatchActionPayload(updateMultimediaMessageMediaActionType, {
messageID: localMessageID,
currentMediaID: localID,
mediaUpdate: {
id,
type: mediaType,
uri,
dimensions,
localMediaSelection: undefined,
loop,
},
});
userTime = Date.now() - start;
}
const processSteps = await reportPromise;
reportPromise = null;
steps.push(...processSteps);
steps.push({
step: 'upload',
success: !!uploadResult,
exceptionMessage: uploadExceptionMessage,
time: Date.now() - uploadStart,
inputFilename: filename,
outputMediaType: uploadResult && uploadResult.mediaType,
outputURI: uploadResult && uploadResult.uri,
outputDimensions: uploadResult && uploadResult.dimensions,
outputLoop: uploadResult && uploadResult.loop,
hasWiFi,
});
const promises = [];
if (shouldDisposePath) {
// If processMedia needed to do any transcoding before upload, we dispose
// of the resultant temporary file here. Since the transcoded temporary
// file is only used for upload, we can dispose of it after processMedia
// (reportPromise) and the upload are complete
promises.push(
(async () => {
const disposeStep = await disposeTempFile(shouldDisposePath);
steps.push(disposeStep);
})(),
);
}
if (selection.captureTime || selection.step === 'photo_paste') {
// If we are uploading a newly captured photo, we dispose of the original
// file here. Note that we try to save photo captures to the camera roll
// if we have permission. Even if we fail, this temporary file isn't
// visible to the user, so there's no point in keeping it around. Since
// the initial URI is used in rendering paths, we have to wait for it to
// be replaced with the remote URI before we can dispose
const captureURI = selection.uri;
promises.push(
(async () => {
const {
steps: clearSteps,
result: capturePath,
} = await this.waitForCaptureURIUnload(captureURI);
steps.push(...clearSteps);
if (!capturePath) {
return;
}
const disposeStep = await disposeTempFile(capturePath);
steps.push(disposeStep);
})(),
);
}
await Promise.all(promises);
return await finish(mediaMissionResult);
}
mediaProcessConfig(localMessageID: string, localID: string) {
const { hasWiFi, viewerID } = this.props;
const onTranscodingProgress = (percent: number) => {
- this.setProgress(localMessageID, localID, 'transcode', percent);
+ this.setProgress(localMessageID, localID, 'transcoding', percent);
};
if (__DEV__ || (viewerID && isStaff(viewerID))) {
return {
hasWiFi,
finalFileHeaderCheck: true,
onTranscodingProgress,
};
}
return { hasWiFi, onTranscodingProgress };
}
setProgress(
localMessageID: string,
localUploadID: string,
processingStep: MultimediaProcessingStep,
progressPercent: number,
) {
this.setState((prevState) => {
const pendingUploads = prevState.pendingUploads[localMessageID];
if (!pendingUploads) {
return {};
}
const pendingUpload = pendingUploads[localUploadID];
if (!pendingUpload) {
return {};
}
const newOutOfHundred = Math.floor(progressPercent * 100);
const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100);
if (newOutOfHundred === oldOutOfHundred) {
return {};
}
const newPendingUploads = {
...pendingUploads,
[localUploadID]: {
...pendingUpload,
progressPercent,
processingStep,
},
};
return {
pendingUploads: {
...prevState.pendingUploads,
[localMessageID]: newPendingUploads,
},
};
});
}
uploadBlob = async (
url: string,
cookie: ?string,
sessionID: ?string,
input: { [key: string]: mixed },
options?: ?FetchJSONOptions,
): Promise => {
invariant(
cookie &&
input.multimedia &&
Array.isArray(input.multimedia) &&
input.multimedia.length === 1 &&
input.multimedia[0] &&
typeof input.multimedia[0] === 'object',
'InputStateContainer.uploadBlob sent incorrect input',
);
const { uri, name, type } = input.multimedia[0];
invariant(
typeof uri === 'string' &&
typeof name === 'string' &&
typeof type === 'string',
'InputStateContainer.uploadBlob sent incorrect input',
);
const parameters = {};
parameters.cookie = cookie;
parameters.filename = name;
for (let key in input) {
if (
key === 'multimedia' ||
key === 'cookie' ||
key === 'sessionID' ||
key === 'filename'
) {
continue;
}
const value = input[key];
invariant(
typeof value === 'string',
'blobUpload calls can only handle string values for non-multimedia keys',
);
parameters[key] = value;
}
let path = uri;
if (Platform.OS === 'android') {
const resolvedPath = pathFromURI(uri);
if (resolvedPath) {
path = resolvedPath;
}
}
const uploadID = await Upload.startUpload({
url,
path,
type: 'multipart',
headers: {
Accept: 'application/json',
},
field: 'multimedia',
parameters,
});
if (options && options.abortHandler) {
options.abortHandler(() => {
Upload.cancelUpload(uploadID);
});
}
return await new Promise((resolve, reject) => {
Upload.addListener('error', uploadID, (data) => {
reject(data.error);
});
Upload.addListener('cancelled', uploadID, () => {
reject(new Error('request aborted'));
});
Upload.addListener('completed', uploadID, (data) => {
try {
resolve(JSON.parse(data.responseBody));
} catch (e) {
reject(e);
}
});
if (options && options.onProgress) {
const { onProgress } = options;
Upload.addListener('progress', uploadID, (data) =>
onProgress(data.progress / 100),
);
}
});
};
handleUploadFailure(
localMessageID: string,
localUploadID: string,
message: string,
) {
this.setState((prevState) => {
const uploads = prevState.pendingUploads[localMessageID];
const upload = uploads[localUploadID];
if (!upload) {
// The upload has been completed before it failed
return {};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[localMessageID]: {
...uploads,
[localUploadID]: {
...upload,
failed: message,
progressPercent: 0,
},
},
},
};
});
}
queueMediaMissionReport(
ids: {| localID: string, localMessageID: string, serverID: ?string |},
mediaMission: MediaMission,
) {
const report: MediaMissionReportCreationRequest = {
type: reportTypes.MEDIA_MISSION,
time: Date.now(),
platformDetails: getConfig().platformDetails,
mediaMission,
uploadServerID: ids.serverID,
uploadLocalID: ids.localID,
messageLocalID: ids.localMessageID,
};
this.props.dispatchActionPayload(queueReportsActionType, {
reports: [report],
});
}
messageHasUploadFailure = (localMessageID: string) => {
const pendingUploads = this.state.pendingUploads[localMessageID];
if (!pendingUploads) {
return false;
}
for (let localUploadID in pendingUploads) {
const { failed } = pendingUploads[localUploadID];
if (failed) {
return true;
}
}
return false;
};
addReply = (message: string) => {
this.replyCallbacks.forEach((addReplyCallback) =>
addReplyCallback(message),
);
};
addReplyListener = (callbackReply: (message: string) => void) => {
this.replyCallbacks.push(callbackReply);
};
removeReplyListener = (callbackReply: (message: string) => void) => {
this.replyCallbacks = this.replyCallbacks.filter(
(candidate) => candidate !== callbackReply,
);
};
retryMultimediaMessage = async (localMessageID: string) => {
const rawMessageInfo = this.props.messageStoreMessages[localMessageID];
invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`);
let pendingUploads = this.state.pendingUploads[localMessageID];
if (!pendingUploads) {
pendingUploads = {};
}
const now = Date.now();
const updateMedia = (media: $ReadOnlyArray): T[] =>
media.map((singleMedia) => {
const oldID = singleMedia.id;
if (!oldID.startsWith('localUpload')) {
// already uploaded
return singleMedia;
}
if (pendingUploads[oldID] && !pendingUploads[oldID].failed) {
// still being uploaded
return singleMedia;
}
// If we have an incomplete upload that isn't in pendingUploads, that
// indicates the app has restarted. We'll reassign a new localID to
// avoid collisions. Note that this isn't necessary for the message ID
// since the localID reducer prevents collisions there
const id = pendingUploads[oldID] ? oldID : getNewLocalID();
const oldSelection = singleMedia.localMediaSelection;
invariant(
oldSelection,
'localMediaSelection should be set on locally created Media',
);
const retries = oldSelection.retries ? oldSelection.retries + 1 : 1;
// We switch for Flow
let selection;
if (oldSelection.step === 'photo_capture') {
selection = { ...oldSelection, sendTime: now, retries };
} else if (oldSelection.step === 'photo_library') {
selection = { ...oldSelection, sendTime: now, retries };
} else if (oldSelection.step === 'photo_paste') {
selection = { ...oldSelection, sendTime: now, retries };
} else {
selection = { ...oldSelection, sendTime: now, retries };
}
if (singleMedia.type === 'photo') {
return {
type: 'photo',
...singleMedia,
id,
localMediaSelection: selection,
};
} else {
return {
type: 'video',
...singleMedia,
id,
localMediaSelection: selection,
};
}
});
let newRawMessageInfo;
// This conditional is for Flow
if (rawMessageInfo.type === messageTypes.MULTIMEDIA) {
newRawMessageInfo = ({
...rawMessageInfo,
time: now,
media: updateMedia(rawMessageInfo.media),
}: RawMediaMessageInfo);
} else if (rawMessageInfo.type === messageTypes.IMAGES) {
newRawMessageInfo = ({
...rawMessageInfo,
time: now,
media: updateMedia(rawMessageInfo.media),
}: RawImagesMessageInfo);
} else {
invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`);
}
const incompleteMedia: Media[] = [];
for (let singleMedia of newRawMessageInfo.media) {
if (singleMedia.id.startsWith('localUpload')) {
incompleteMedia.push(singleMedia);
}
}
if (incompleteMedia.length === 0) {
this.dispatchMultimediaMessageAction(newRawMessageInfo);
this.setState((prevState) => ({
pendingUploads: {
...prevState.pendingUploads,
[localMessageID]: {},
},
}));
return;
}
const retryMedia = incompleteMedia.filter(
({ id }) => !pendingUploads[id] || pendingUploads[id].failed,
);
if (retryMedia.length === 0) {
// All media are already in the process of being uploaded
return;
}
// We're not actually starting the send here,
// we just use this action to update the message in Redux
this.props.dispatchActionPayload(
sendMultimediaMessageActionTypes.started,
newRawMessageInfo,
);
// We clear out the failed status on individual media here,
// which makes the UI show pending status instead of error messages
for (let { id } of retryMedia) {
pendingUploads[id] = {
failed: null,
progressPercent: 0,
processingStep: null,
};
}
this.setState((prevState) => ({
pendingUploads: {
...prevState.pendingUploads,
[localMessageID]: pendingUploads,
},
}));
const selectionsWithIDs = retryMedia.map((singleMedia) => {
const { id, localMediaSelection } = singleMedia;
invariant(
localMediaSelection,
'localMediaSelection should be set on locally created Media',
);
return { selection: localMediaSelection, localID: id };
});
await this.uploadFiles(localMessageID, selectionsWithIDs);
};
registerSendCallback = (callback: () => void) => {
this.sendCallbacks.push(callback);
};
unregisterSendCallback = (callback: () => void) => {
this.sendCallbacks = this.sendCallbacks.filter(
(candidate) => candidate !== callback,
);
};
reportURIDisplayed = (uri: string, loaded: boolean) => {
const prevActiveURI = this.activeURIs.get(uri);
const curCount = prevActiveURI && prevActiveURI.count;
const prevCount = curCount ? curCount : 0;
const count = loaded ? prevCount + 1 : prevCount - 1;
const prevOnClear = prevActiveURI && prevActiveURI.onClear;
const onClear = prevOnClear ? prevOnClear : [];
const activeURI = { count, onClear };
if (count) {
this.activeURIs.set(uri, activeURI);
return;
}
this.activeURIs.delete(uri);
for (let callback of onClear) {
callback();
}
};
waitForCaptureURIUnload(uri: string) {
const start = Date.now();
const path = pathFromURI(uri);
if (!path) {
return Promise.resolve({
result: null,
steps: [
{
step: 'wait_for_capture_uri_unload',
success: false,
time: Date.now() - start,
uri,
},
],
});
}
const getResult = () => ({
result: path,
steps: [
{
step: 'wait_for_capture_uri_unload',
success: true,
time: Date.now() - start,
uri,
},
],
});
const activeURI = this.activeURIs.get(uri);
if (!activeURI) {
return Promise.resolve(getResult());
}
return new Promise((resolve) => {
const finish = () => resolve(getResult());
const newActiveURI = {
...activeURI,
onClear: [...activeURI.onClear, finish],
};
this.activeURIs.set(uri, newActiveURI);
});
}
render() {
const inputState = this.inputStateSelector(this.state);
return (
{this.props.children}
);
}
}
const mediaCreationLoadingStatusSelector = createLoadingStatusSelector(
sendMultimediaMessageActionTypes,
);
const textCreationLoadingStatusSelector = createLoadingStatusSelector(
sendTextMessageActionTypes,
);
export default connect(
(state: AppState) => ({
viewerID: state.currentUserInfo && state.currentUserInfo.id,
nextLocalID: state.nextLocalID,
messageStoreMessages: state.messageStore.messages,
ongoingMessageCreation:
combineLoadingStatuses(
mediaCreationLoadingStatusSelector(state),
textCreationLoadingStatusSelector(state),
) === 'loading',
hasWiFi: state.connectivity.hasWiFi,
}),
{ uploadMultimedia, sendMultimediaMessage, sendTextMessage },
)(InputStateContainer);
diff --git a/native/input/input-state.js b/native/input/input-state.js
index f112c54a3..45742df8c 100644
--- a/native/input/input-state.js
+++ b/native/input/input-state.js
@@ -1,77 +1,77 @@
// @flow
import PropTypes from 'prop-types';
import * as React from 'react';
import type { NativeMediaSelection } from 'lib/types/media-types';
import type { RawTextMessageInfo } from 'lib/types/messages/text';
-export type MultimediaProcessingStep = 'transcode' | 'upload';
+export type MultimediaProcessingStep = 'transcoding' | 'uploading';
export type PendingMultimediaUpload = {|
+failed: ?string,
+progressPercent: number,
+processingStep: ?MultimediaProcessingStep,
|};
const pendingMultimediaUploadPropType = PropTypes.shape({
failed: PropTypes.string,
progressPercent: PropTypes.number.isRequired,
- processingStep: PropTypes.oneOf(['transcode', 'upload']),
+ processingStep: PropTypes.oneOf(['transcoding', 'uploading']),
});
export type MessagePendingUploads = {
[localUploadID: string]: PendingMultimediaUpload,
};
const messagePendingUploadsPropType = PropTypes.objectOf(
pendingMultimediaUploadPropType,
);
export type PendingMultimediaUploads = {
[localMessageID: string]: MessagePendingUploads,
};
const pendingMultimediaUploadsPropType = PropTypes.objectOf(
messagePendingUploadsPropType,
);
export type InputState = {|
pendingUploads: PendingMultimediaUploads,
sendTextMessage: (messageInfo: RawTextMessageInfo) => void,
sendMultimediaMessage: (
threadID: string,
selections: $ReadOnlyArray,
) => Promise,
addReply: (text: string) => void,
addReplyListener: ((message: string) => void) => void,
removeReplyListener: ((message: string) => void) => void,
messageHasUploadFailure: (localMessageID: string) => boolean,
retryMultimediaMessage: (localMessageID: string) => Promise,
registerSendCallback: (() => void) => void,
unregisterSendCallback: (() => void) => void,
uploadInProgress: () => boolean,
reportURIDisplayed: (uri: string, loaded: boolean) => void,
|};
const inputStatePropType = PropTypes.shape({
pendingUploads: pendingMultimediaUploadsPropType.isRequired,
sendTextMessage: PropTypes.func.isRequired,
sendMultimediaMessage: PropTypes.func.isRequired,
addReply: PropTypes.func.isRequired,
addReplyListener: PropTypes.func.isRequired,
removeReplyListener: PropTypes.func.isRequired,
messageHasUploadFailure: PropTypes.func.isRequired,
retryMultimediaMessage: PropTypes.func.isRequired,
uploadInProgress: PropTypes.func.isRequired,
reportURIDisplayed: PropTypes.func.isRequired,
});
const InputStateContext = React.createContext(null);
export {
messagePendingUploadsPropType,
pendingMultimediaUploadPropType,
inputStatePropType,
InputStateContext,
};