diff --git a/CHANGELOG.md b/CHANGELOG.md
index b98e4f991..fad40dad6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,37 @@
# Mattermost Mobile Apps Changelog
+## 1.25.0 Release
+- Release Date: November 16, 2019
+- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
+
+### Compatibility
+ - Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
+ - iPhone 5s devices and later with iOS 11+ is required.
+
+### Bug Fixes
+ - Fixed an issue where Mattermost monokai theme no longer worked properly on mobile apps.
+ - Fixed an issue on Android where the notification badge count didn't update when using multiple channels.
+ - Fixed an issue on Android where test notifications did not work properly.
+ - Fixed an issue where "In-app" notifications caused the app badge count to get out of sync.
+ - Fixed an issue on Android where email notification setting displayed was not updated when the setting was changed.
+ - Fixed an issue where Favorite channels list didn't update if the app was running in the background.
+ - Fixed an issue where the timezone setting did not update when changing it back to set automatically.
+ - Fixed an issue on iOS where clicking on a hashtag from "recent mentions" (or flagged posts) returned the user to the channel instead of displaying hashtag search results.
+ - Fixed an issue where tapping on a hashtag engaged a keyboard for a moment before displaying search results.
+ - Fixed an issue where posts of the same thread appeared to be from different threads if separated by a new message line.
+ - Fixed styling issues on iOS for Name, Purpose and Header information on the channel info screen.
+ - Fixed styling issues with bot posts timestamps in search results and pinned posts.
+ - Fixed styling issues on single sign-on screen in landscape view on iOS iPhone X and later.
+ - Fixed styling issues on iOS for the Helper text on Settings screens.
+ - Fixed an issue where the thread view header theme was inconsistent during transition back to main channel view.
+ - Fixed an issue on iOS where the navigation bar tucked under the phone's status bar when switching orientation.
+ - Fixed an issue on iOS where the keyboard flashed darker when Automatic Replies had been previously enabled.
+ - Fixed an issue on Android where uploading pictures from storage or camera required unwanted permissions.
+ - Fixed an issue where ``mobile.message_length.message`` did not match webapp's ``create_post.error_message``.
+
+### Known Issues
+ - App slows down when opening a channel with large number of animated emoji. [MM-15792](https://mattermost.atlassian.net/browse/MM-15792)
+
## 1.24.0 Release
- Release Date: October 16, 2019
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2f38a30ed..6bf0a8c38 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -268,6 +268,7 @@ dependencies {
implementation project(':react-native-android-open-settings')
implementation project(':react-native-haptic-feedback')
+ implementation project(':react-native-fast-image')
// For animated GIF support
implementation 'com.facebook.fresco:fresco:2.0.0'
implementation 'com.facebook.fresco:animated-gif:2.0.0'
diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java
index 38eb0ee70..852efb43e 100644
--- a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java
+++ b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java
@@ -31,6 +31,7 @@ import com.reactnativecommunity.asyncstorage.AsyncStorageModule;
import com.reactnativecommunity.netinfo.NetInfoModule;
import com.reactnativecommunity.webview.RNCWebViewPackage;
import io.sentry.RNSentryModule;
+import com.dylanvann.fastimage.FastImageViewPackage;
import com.levelasquez.androidopensettings.AndroidOpenSettings;
import com.mkuczera.RNReactNativeHapticFeedbackModule;
@@ -192,6 +193,7 @@ public class MainApplication extends NavigationApplication implements INotificat
};
}
},
+ new FastImageViewPackage(),
new RNCWebViewPackage(),
new SvgPackage(),
new LinearGradientPackage(),
diff --git a/android/settings.gradle b/android/settings.gradle
index baaaf279c..d5c0323b4 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -3,6 +3,8 @@ include ':@sentry_react-native'
project(':@sentry_react-native').projectDir = new File(rootProject.projectDir, '../node_modules/@sentry/react-native/android')
include ':react-native-android-open-settings'
project(':react-native-android-open-settings').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-android-open-settings/android')
+include ':react-native-fast-image'
+project(':react-native-fast-image').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fast-image/android')
include ':react-native-haptic-feedback'
project(':react-native-haptic-feedback').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-haptic-feedback/android')
include ':react-native-gesture-handler'
diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js
index 4d89dcd77..fed9f0c7d 100644
--- a/app/actions/views/channel.js
+++ b/app/actions/views/channel.js
@@ -14,6 +14,8 @@ import {
leaveChannel as serviceLeaveChannel,
selectChannel,
getChannelStats,
+ getChannels,
+ getArchivedChannels,
} from 'mattermost-redux/actions/channels';
import {
getPosts,
@@ -73,6 +75,26 @@ export function loadChannelsByTeamName(teamName) {
};
}
+export function loadPublicAndArchivedChannels(teamId, publicPage, archivedPage, perPage, shouldLoadArchivedChannels) {
+ return async (dispatch) => {
+ return dispatch(getChannels(
+ teamId,
+ publicPage,
+ perPage
+ )).then(async (publicChannels) => {
+ if (shouldLoadArchivedChannels) {
+ const archivedChannels = await dispatch(getArchivedChannels(
+ teamId,
+ archivedPage,
+ perPage
+ ));
+ return archivedChannels;
+ }
+ return publicChannels;
+ });
+ };
+}
+
export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
return async (dispatch, getState) => {
const state = getState();
diff --git a/app/components/autocomplete/autocomplete_section_header.js b/app/components/autocomplete/autocomplete_section_header.js
index 997a8cf44..0fb6f6d80 100644
--- a/app/components/autocomplete/autocomplete_section_header.js
+++ b/app/components/autocomplete/autocomplete_section_header.js
@@ -34,7 +34,12 @@ export default class AutocompleteSectionHeader extends PureComponent {
defaultMessage={defaultMessage}
style={style.sectionText}
/>
- {loading && }
+ {loading &&
+
+ }
);
diff --git a/app/components/autocomplete/channel_mention_item/index.js b/app/components/autocomplete/channel_mention_item/index.js
index c70208684..1671fd391 100644
--- a/app/components/autocomplete/channel_mention_item/index.js
+++ b/app/components/autocomplete/channel_mention_item/index.js
@@ -20,7 +20,7 @@ function mapStateToProps(state, ownProps) {
let isBot = false;
let isGuest = false;
- if (channel.type === General.DM_CHANNEL) {
+ if (channel?.type === General.DM_CHANNEL) {
const teammate = getUser(state, channel.teammate_id);
if (teammate) {
displayName = teammate.username;
@@ -31,8 +31,8 @@ function mapStateToProps(state, ownProps) {
return {
displayName,
- name: channel.name,
- type: channel.type,
+ name: channel?.name,
+ type: channel?.type,
isBot,
isGuest,
theme: getTheme(state),
diff --git a/app/components/autocomplete_selector/autocomplete_selector.js b/app/components/autocomplete_selector/autocomplete_selector.js
index 83abb8488..c5b8c1499 100644
--- a/app/components/autocomplete_selector/autocomplete_selector.js
+++ b/app/components/autocomplete_selector/autocomplete_selector.js
@@ -36,6 +36,7 @@ export default class AutocompleteSelector extends PureComponent {
errorText: PropTypes.node,
roundedBorders: PropTypes.bool,
isLandscape: PropTypes.bool.isRequired,
+ disabled: PropTypes.bool,
};
static contextTypes = {
@@ -119,6 +120,7 @@ export default class AutocompleteSelector extends PureComponent {
showRequiredAsterisk,
roundedBorders,
isLandscape,
+ disabled,
} = this.props;
const {selectedText} = this.state;
const style = getStyleSheet(theme);
@@ -188,9 +190,10 @@ export default class AutocompleteSelector extends PureComponent {
{labelContent}
{
color: theme.errorTextColor,
fontSize: 14,
},
+ disabled: {
+ opacity: 0.5,
+ },
};
});
diff --git a/app/components/custom_list/__snapshots__/index.test.js.snap b/app/components/custom_list/__snapshots__/index.test.js.snap
index 38087a1d3..e205d5f3e 100644
--- a/app/components/custom_list/__snapshots__/index.test.js.snap
+++ b/app/components/custom_list/__snapshots__/index.test.js.snap
@@ -31,6 +31,16 @@ exports[`CustomList should match snapshot with FlatList 1`] = `
onEndReachedThreshold={2}
onLayout={[Function]}
onScroll={[Function]}
+ refreshControl={
+
+ }
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={60}
diff --git a/app/components/custom_list/channel_list_row/channel_list_row.js b/app/components/custom_list/channel_list_row/channel_list_row.js
index 01feaf7a0..096339ae2 100644
--- a/app/components/custom_list/channel_list_row/channel_list_row.js
+++ b/app/components/custom_list/channel_list_row/channel_list_row.js
@@ -15,6 +15,7 @@ import CustomListRow from 'app/components/custom_list/custom_list_row';
export default class ChannelListRow extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
+ isArchived: PropTypes.bool,
theme: PropTypes.object.isRequired,
channel: PropTypes.object.isRequired,
...CustomListRow.propTypes,
@@ -53,7 +54,7 @@ export default class ChannelListRow extends React.PureComponent {
diff --git a/app/components/custom_list/index.js b/app/components/custom_list/index.js
index 42ae0461d..5b2acf0bb 100644
--- a/app/components/custom_list/index.js
+++ b/app/components/custom_list/index.js
@@ -3,7 +3,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
-import {FlatList, Keyboard, Platform, SectionList, Text, View} from 'react-native';
+import {FlatList, Keyboard, Platform, RefreshControl, SectionList, Text, View} from 'react-native';
import {ListTypes} from 'app/constants';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
@@ -113,6 +113,14 @@ export default class CustomList extends PureComponent {
const {data, extraData, theme, onRefresh, refreshing} = this.props;
const style = getStyleFromTheme(theme);
+ const refreshControl = (
+ );
+
return (
-
+
);
}
diff --git a/app/components/emoji/emoji.js b/app/components/emoji/emoji.js
index 6f467fe3b..841070c8f 100644
--- a/app/components/emoji/emoji.js
+++ b/app/components/emoji/emoji.js
@@ -9,6 +9,7 @@ import {
StyleSheet,
Text,
} from 'react-native';
+import FastImage from 'react-native-fast-image';
import CustomPropTypes from 'app/constants/custom_prop_types';
import ImageCacheManager from 'app/utils/image_cache_manager';
@@ -140,7 +141,7 @@ export default class Emoji extends React.PureComponent {
}
return (
-
-
+
);
};
diff --git a/app/components/file_attachment_list/__snapshots__/file_attachment.test.js.snap b/app/components/file_attachment_list/__snapshots__/file_attachment.test.js.snap
index a26f8be29..afadadbab 100644
--- a/app/components/file_attachment_list/__snapshots__/file_attachment.test.js.snap
+++ b/app/components/file_attachment_list/__snapshots__/file_attachment.test.js.snap
@@ -5,77 +5,70 @@ exports[`FileAttachment should match snapshot 1`] = `
style={
Array [
Object {
- "borderColor": "rgba(61,60,64,0.2)",
- "borderRadius": 2,
+ "borderColor": "rgba(61,60,64,0.4)",
+ "borderRadius": 5,
"borderWidth": 1,
"flex": 1,
"flexDirection": "row",
- "marginRight": 10,
"marginTop": 10,
- "width": 300,
},
]
}
>
-
-
-
-
+ >
+
+
+
+
`;
diff --git a/app/components/file_attachment_list/__snapshots__/file_attachment_list.test.js.snap b/app/components/file_attachment_list/__snapshots__/file_attachment_list.test.js.snap
index f1387431d..92df63048 100644
--- a/app/components/file_attachment_list/__snapshots__/file_attachment_list.test.js.snap
+++ b/app/components/file_attachment_list/__snapshots__/file_attachment_list.test.js.snap
@@ -1,71 +1,1395 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`PostAttachmentOpenGraph should match snapshot with a single image file 1`] = `
-
-
+
-
+ >
+
+
+
+
+`;
+
+exports[`FileAttachmentList should match snapshot with combination of image and non-image attachments 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`FileAttachmentList should match snapshot with four image files 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`FileAttachmentList should match snapshot with more than four image files 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`FileAttachmentList should match snapshot with non-image attachment 1`] = `
+
+
+
+
+
+`;
+
+exports[`FileAttachmentList should match snapshot with three image files 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`FileAttachmentList should match snapshot with two image files 1`] = `
+
+
+
+
+
+
+
+
+
+
`;
diff --git a/app/components/file_attachment_list/file_attachment.js b/app/components/file_attachment_list/file_attachment.js
index e7d0b8009..7a50c637d 100644
--- a/app/components/file_attachment_list/file_attachment.js
+++ b/app/components/file_attachment_list/file_attachment.js
@@ -4,14 +4,18 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
+ Dimensions,
+ PixelRatio,
Text,
View,
+ StyleSheet,
} from 'react-native';
import * as Utils from 'mattermost-redux/utils/file_utils.js';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {isDocument, isGif} from 'app/utils/file';
+import {calculateDimensions} from 'app/utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import FileAttachmentDocument from './file_attachment_document';
@@ -28,10 +32,14 @@ export default class FileAttachment extends PureComponent {
onLongPress: PropTypes.func,
onPreviewPress: PropTypes.func,
theme: PropTypes.object.isRequired,
+ wrapperWidth: PropTypes.number,
+ isSingleImage: PropTypes.bool,
+ nonVisibleImagesCount: PropTypes.number,
};
static defaultProps = {
onPreviewPress: () => true,
+ wrapperWidth: 300,
};
handleCaptureRef = (ref) => {
@@ -51,7 +59,7 @@ export default class FileAttachment extends PureComponent {
};
renderFileInfo() {
- const {file, theme} = this.props;
+ const {file, onLongPress, theme} = this.props;
const {data} = file;
const style = getStyleSheet(theme);
@@ -60,24 +68,31 @@ export default class FileAttachment extends PureComponent {
}
return (
-
-
- {file.caption.trim()}
-
-
+
+
- {`${data.extension.toUpperCase()} ${Utils.getFormattedFileSize(data)}`}
+ {file.caption.trim()}
-
-
+
+
+ {`${Utils.getFormattedFileSize(data)}`}
+
+
+
+
);
}
@@ -85,75 +100,118 @@ export default class FileAttachment extends PureComponent {
this.documentElement = ref;
};
+ renderMoreImagesOverlay = (value) => {
+ if (!value) {
+ return null;
+ }
+
+ const {theme} = this.props;
+ const style = getStyleSheet(theme);
+
+ return (
+
+
+ {`+${value}`}
+
+
+ );
+ };
+
+ getImageDimensions = (file) => {
+ const {isSingleImage, wrapperWidth} = this.props;
+ const viewPortHeight = this.getViewPortHeight();
+
+ if (isSingleImage) {
+ return calculateDimensions(file?.height, file?.width, wrapperWidth, viewPortHeight);
+ }
+
+ return null;
+ };
+
+ getViewPortHeight = () => {
+ const dimensions = Dimensions.get('window');
+ const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
+
+ return viewPortHeight;
+ };
+
render() {
const {
canDownloadFiles,
file,
theme,
onLongPress,
+ isSingleImage,
+ nonVisibleImagesCount,
} = this.props;
const {data} = file;
const style = getStyleSheet(theme);
let fileAttachmentComponent;
if ((data && data.has_preview_image) || file.loading || isGif(data)) {
+ const imageDimensions = this.getImageDimensions(data);
+
fileAttachmentComponent = (
+ {this.renderMoreImagesOverlay(nonVisibleImagesCount, theme)}
);
} else if (isDocument(data)) {
fileAttachmentComponent = (
-
+
+
+
+
+ {this.renderFileInfo()}
+
);
} else {
fileAttachmentComponent = (
-
-
-
+
+
+
+
+
+
+ {this.renderFileInfo()}
+
);
}
- return (
-
- {fileAttachmentComponent}
-
- {this.renderFileInfo()}
-
-
- );
+ return fileAttachmentComponent;
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
+ const scale = Dimensions.get('window').width / 320;
+
return {
attachmentContainer: {
flex: 1,
@@ -168,33 +226,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
marginTop: 3,
},
fileInfo: {
- marginLeft: 2,
fontSize: 14,
- color: changeOpacity(theme.centerChannelColor, 0.5),
- },
- fileInfoContainer: {
- flex: 1,
- paddingHorizontal: 8,
- paddingVertical: 5,
- borderLeftWidth: 1,
- borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2),
+ color: theme.centerChannelColor,
},
fileName: {
flexDirection: 'column',
flexWrap: 'wrap',
- marginLeft: 2,
fontSize: 14,
+ fontWeight: '600',
color: theme.centerChannelColor,
+ paddingRight: 10,
},
fileWrapper: {
flex: 1,
flexDirection: 'row',
marginTop: 10,
- marginRight: 10,
borderWidth: 1,
- borderColor: changeOpacity(theme.centerChannelColor, 0.2),
- borderRadius: 2,
- width: 300,
+ borderColor: changeOpacity(theme.centerChannelColor, 0.4),
+ borderRadius: 5,
+ },
+ iconWrapper: {
+ marginHorizontal: 20,
+ marginVertical: 10,
},
circularProgress: {
width: '100%',
@@ -211,5 +264,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
alignItems: 'center',
justifyContent: 'center',
},
+ moreImagesWrapper: {
+ ...StyleSheet.absoluteFill,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
+ borderRadius: 5,
+ },
+ moreImagesText: {
+ color: theme.sidebarHeaderTextColor,
+ fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)),
+ fontFamily: 'Open Sans',
+ textAlign: 'center',
+ },
};
});
diff --git a/app/components/file_attachment_list/file_attachment_document/file_attachment_document.js b/app/components/file_attachment_list/file_attachment_document/file_attachment_document.js
index 896c6d894..8fc6419c2 100644
--- a/app/components/file_attachment_list/file_attachment_document/file_attachment_document.js
+++ b/app/components/file_attachment_list/file_attachment_document/file_attachment_document.js
@@ -10,6 +10,7 @@ import {
Platform,
StatusBar,
StyleSheet,
+ Text,
View,
} from 'react-native';
import OpenFile from 'react-native-doc-viewer';
@@ -27,8 +28,9 @@ import mattermostBucket from 'app/mattermost_bucket';
import {changeOpacity} from 'app/utils/theme';
import {goToScreen} from 'app/actions/navigation';
+import {ATTACHMENT_ICON_HEIGHT, ATTACHMENT_ICON_WIDTH} from 'app/constants/attachment';
+
const {DOCUMENTS_PATH} = DeviceTypes;
-const DOWNLOADING_OFFSET = 28;
const TEXT_PREVIEW_FORMATS = [
'application/json',
'application/x-x509-ca-cert',
@@ -50,10 +52,10 @@ export default class FileAttachmentDocument extends PureComponent {
};
static defaultProps = {
- iconHeight: 47,
- iconWidth: 47,
- wrapperHeight: 80,
- wrapperWidth: 80,
+ iconHeight: ATTACHMENT_ICON_HEIGHT,
+ iconWidth: ATTACHMENT_ICON_WIDTH,
+ wrapperHeight: ATTACHMENT_ICON_HEIGHT,
+ wrapperWidth: ATTACHMENT_ICON_WIDTH,
};
static contextTypes = {
@@ -284,16 +286,6 @@ export default class FileAttachmentDocument extends PureComponent {
}
};
- renderProgress = () => {
- const {wrapperWidth} = this.props;
-
- return (
-
- {this.renderFileAttachmentIcon()}
-
- );
- };
-
showDownloadDisabledAlert = () => {
const {intl} = this.context;
@@ -338,14 +330,6 @@ export default class FileAttachmentDocument extends PureComponent {
renderFileAttachmentIcon = () => {
const {backgroundColor, iconHeight, iconWidth, file, theme, wrapperHeight, wrapperWidth} = this.props;
- const {downloading} = this.state;
- let height = wrapperHeight;
- let width = wrapperWidth;
-
- if (downloading) {
- height -= DOWNLOADING_OFFSET;
- width -= DOWNLOADING_OFFSET;
- }
return (
);
}
- render() {
- const {onLongPress, theme, wrapperHeight} = this.props;
- const {downloading, progress} = this.state;
+ renderDownloadProgres = () => {
+ const {theme} = this.props;
+ return (
+
+ {`${this.state.progress}%`}
+
+ );
+ };
+ render() {
+ const {onLongPress, theme} = this.props;
+ const {downloading, progress} = this.state;
let fileAttachmentComponent;
if (downloading) {
fileAttachmentComponent = (
-
- {this.renderProgress}
-
+
+
+ {this.renderDownloadProgres}
+
+
);
} else {
fileAttachmentComponent = this.renderFileAttachmentIcon();
@@ -396,11 +390,9 @@ export default class FileAttachmentDocument extends PureComponent {
const style = StyleSheet.create({
circularProgressContent: {
- alignItems: 'center',
- height: '100%',
- justifyContent: 'center',
- left: -circularProgressWidth,
- position: 'absolute',
- top: 0,
+ left: -(circularProgressWidth - 2),
+ top: 4,
+ width: 36,
+ height: 48,
},
});
diff --git a/app/components/file_attachment_list/file_attachment_icon.js b/app/components/file_attachment_list/file_attachment_icon.js
index 0c84b0282..08f6ba039 100644
--- a/app/components/file_attachment_list/file_attachment_icon.js
+++ b/app/components/file_attachment_list/file_attachment_icon.js
@@ -19,9 +19,13 @@ import imageIcon from 'assets/images/icons/image.png';
import patchIcon from 'assets/images/icons/patch.png';
import pdfIcon from 'assets/images/icons/pdf.png';
import pptIcon from 'assets/images/icons/ppt.png';
+import textIcon from 'assets/images/icons/text.png';
import videoIcon from 'assets/images/icons/video.png';
import wordIcon from 'assets/images/icons/word.png';
+import {ATTACHMENT_ICON_HEIGHT, ATTACHMENT_ICON_WIDTH} from 'app/constants/attachment';
+import {changeOpacity} from 'app/utils/theme';
+
const ICON_PATH_FROM_FILE_TYPE = {
audio: audioIcon,
code: codeIcon,
@@ -31,6 +35,7 @@ const ICON_PATH_FROM_FILE_TYPE = {
pdf: pdfIcon,
presentation: pptIcon,
spreadsheet: excelIcon,
+ text: textIcon,
video: videoIcon,
word: wordIcon,
};
@@ -44,14 +49,14 @@ export default class FileAttachmentIcon extends PureComponent {
onCaptureRef: PropTypes.func,
wrapperHeight: PropTypes.number,
wrapperWidth: PropTypes.number,
+ theme: PropTypes.object,
};
static defaultProps = {
- backgroundColor: '#fff',
- iconHeight: 60,
- iconWidth: 60,
- wrapperHeight: 80,
- wrapperWidth: 80,
+ iconHeight: ATTACHMENT_ICON_HEIGHT,
+ iconWidth: ATTACHMENT_ICON_WIDTH,
+ wrapperHeight: ATTACHMENT_ICON_HEIGHT,
+ wrapperWidth: ATTACHMENT_ICON_WIDTH,
};
getFileIconPath(file) {
@@ -68,16 +73,17 @@ export default class FileAttachmentIcon extends PureComponent {
};
render() {
- const {backgroundColor, file, iconHeight, iconWidth, wrapperHeight, wrapperWidth} = this.props;
+ const {backgroundColor, file, iconHeight, iconWidth, wrapperHeight, wrapperWidth, theme} = this.props;
const source = this.getFileIconPath(file);
+ const bgColor = backgroundColor || theme.centerChannelBg || 'transparent';
return (
@@ -89,12 +95,5 @@ const styles = StyleSheet.create({
fileIconWrapper: {
alignItems: 'center',
justifyContent: 'center',
- borderTopLeftRadius: 2,
- borderBottomLeftRadius: 2,
- },
- icon: {
- borderTopLeftRadius: 2,
- borderBottomLeftRadius: 2,
- backgroundColor: '#fff',
},
});
diff --git a/app/components/file_attachment_list/file_attachment_image.js b/app/components/file_attachment_list/file_attachment_image.js
index e087447da..b37afe6fa 100644
--- a/app/components/file_attachment_list/file_attachment_image.js
+++ b/app/components/file_attachment_list/file_attachment_image.js
@@ -15,9 +15,13 @@ import ProgressiveImage from 'app/components/progressive_image';
import {isGif} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
import ImageCacheManager from 'app/utils/image_cache_manager';
+import {changeOpacity} from 'app/utils/theme';
import thumb from 'assets/images/thumb.png';
+const SMALL_IMAGE_MAX_HEIGHT = 48;
+const SMALL_IMAGE_MAX_WIDTH = 48;
+
const IMAGE_SIZE = {
Fullsize: 'fullsize',
Preview: 'preview',
@@ -35,22 +39,18 @@ export default class FileAttachmentImage extends PureComponent {
]),
imageWidth: PropTypes.number,
onCaptureRef: PropTypes.func,
+ theme: PropTypes.object,
resizeMode: PropTypes.string,
resizeMethod: PropTypes.string,
wrapperHeight: PropTypes.number,
wrapperWidth: PropTypes.number,
+ isSingleImage: PropTypes.bool,
+ imageDimensions: PropTypes.object,
};
static defaultProps = {
- fadeInOnLoad: false,
- imageHeight: 80,
- imageSize: IMAGE_SIZE.Preview,
- imageWidth: 80,
- loading: false,
resizeMode: 'cover',
resizeMethod: 'resize',
- wrapperHeight: 80,
- wrapperWidth: 80,
};
constructor(props) {
@@ -72,15 +72,11 @@ export default class FileAttachmentImage extends PureComponent {
};
}
- calculateNeededWidth = (height, width, newHeight) => {
- const ratio = width / height;
-
- let newWidth = newHeight * ratio;
- if (newWidth < newHeight) {
- newWidth = newHeight;
+ boxPlaceholder = () => {
+ if (this.props.isSingleImage) {
+ return null;
}
-
- return newWidth;
+ return ();
};
handleCaptureRef = (ref) => {
@@ -91,27 +87,7 @@ export default class FileAttachmentImage extends PureComponent {
}
};
- render() {
- const {
- file,
- imageHeight,
- imageWidth,
- imageSize,
- resizeMethod,
- resizeMode,
- wrapperHeight,
- wrapperWidth,
- } = this.props;
-
- let height = imageHeight;
- let width = imageWidth;
- let imageStyle = {height, width};
- if (imageSize === IMAGE_SIZE.Preview) {
- height = 80;
- width = this.calculateNeededWidth(file.height, file.width, height) || 80;
- imageStyle = {height, width, position: 'absolute', top: 0, left: 0, borderBottomLeftRadius: 2, borderTopLeftRadius: 2};
- }
-
+ imageProps = (file) => {
const imageProps = {};
if (file.localPath) {
imageProps.defaultSource = {uri: file.localPath};
@@ -119,20 +95,73 @@ export default class FileAttachmentImage extends PureComponent {
imageProps.thumbnailUri = Client4.getFileThumbnailUrl(file.id);
imageProps.imageUri = Client4.getFilePreviewUrl(file.id);
}
+ return imageProps;
+ };
+
+ renderSmallImage = () => {
+ const {file, isSingleImage, resizeMethod, theme} = this.props;
+
+ let wrapperStyle = style.fileImageWrapper;
+
+ if (isSingleImage) {
+ wrapperStyle = style.singleSmallImageWrapper;
+
+ if (file.width > SMALL_IMAGE_MAX_WIDTH) {
+ wrapperStyle = [wrapperStyle, {width: '100%'}];
+ }
+ }
return (
+ {this.boxPlaceholder()}
+
+
+
+
+ );
+ };
+
+ render() {
+ const {
+ file,
+ imageDimensions,
+ resizeMethod,
+ resizeMode,
+ } = this.props;
+
+ if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) {
+ return this.renderSmallImage();
+ }
+
+ return (
+
+ {this.boxPlaceholder()}
);
@@ -140,17 +169,28 @@ export default class FileAttachmentImage extends PureComponent {
}
const style = StyleSheet.create({
- fileImageWrapper: {
- alignItems: 'center',
- justifyContent: 'center',
- borderBottomLeftRadius: 2,
- borderTopLeftRadius: 2,
+ imagePreview: {
+ ...StyleSheet.absoluteFill,
},
- loaderContainer: {
- position: 'absolute',
- height: '100%',
- width: '100%',
- alignItems: 'center',
+ fileImageWrapper: {
+ borderRadius: 5,
+ overflow: 'hidden',
+ },
+ boxPlaceholder: {
+ paddingBottom: '100%',
+ },
+ smallImageBorder: {
+ borderWidth: 1,
+ borderRadius: 5,
+ },
+ smallImageOverlay: {
+ ...StyleSheet.absoluteFill,
justifyContent: 'center',
+ alignItems: 'center',
+ },
+ singleSmallImageWrapper: {
+ height: SMALL_IMAGE_MAX_HEIGHT,
+ width: SMALL_IMAGE_MAX_WIDTH,
+ overflow: 'hidden',
},
});
diff --git a/app/components/file_attachment_list/file_attachment_list.js b/app/components/file_attachment_list/file_attachment_list.js
index fd8a15596..ceb1fde43 100644
--- a/app/components/file_attachment_list/file_attachment_list.js
+++ b/app/components/file_attachment_list/file_attachment_list.js
@@ -1,15 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import React, {Component} from 'react';
+import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
-import {
- ScrollView,
- StyleSheet,
-} from 'react-native';
+import {Dimensions, StyleSheet, View} from 'react-native';
+import AsyncStorage from '@react-native-community/async-storage';
import {Client4} from 'mattermost-redux/client';
+import EventEmitter from 'mattermost-redux/utils/event_emitter';
+import {TABLET_WIDTH} from 'app/components/sidebars/drawer_layout';
+import {DeviceTypes} from 'app/constants';
+import mattermostManaged from 'app/mattermost_managed';
import {isDocument, isGif, isVideo} from 'app/utils/file';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {previewImageAtIndex} from 'app/utils/images';
@@ -18,7 +20,11 @@ import {emptyFunction} from 'app/utils/general';
import FileAttachment from './file_attachment';
-export default class FileAttachmentList extends Component {
+const MAX_VISIBLE_ROW_IMAGES = 4;
+const VIEWPORT_IMAGE_OFFSET = 70;
+const VIEWPORT_IMAGE_REPLY_OFFSET = 11;
+
+export default class FileAttachmentList extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
loadFilesForPostIfNecessary: PropTypes.func.isRequired,
@@ -30,6 +36,7 @@ export default class FileAttachmentList extends Component {
onLongPress: PropTypes.func,
postId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
+ isReplyPost: PropTypes.bool,
};
static defaultProps = {
@@ -40,19 +47,25 @@ export default class FileAttachmentList extends Component {
super(props);
this.items = [];
- this.previewItems = [];
-
+ this.filesForGallery = this.getFilesForGallery(props);
this.state = {
loadingFiles: props.files.length === 0,
};
- this.buildGalleryFiles(props).then((results) => {
+ this.buildGalleryFiles().then((results) => {
this.galleryFiles = results;
});
}
componentDidMount() {
const {files} = this.props;
+
+ this.mounted = true;
+ this.handlePermanentSidebar();
+ this.handleDimensions();
+ EventEmitter.on(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
+ Dimensions.addEventListener('change', this.handleDimensions);
+
if (files.length === 0) {
this.loadFilesForPost();
}
@@ -60,7 +73,8 @@ export default class FileAttachmentList extends Component {
componentWillReceiveProps(nextProps) {
if (this.props.files !== nextProps.files) {
- this.buildGalleryFiles(nextProps).then((results) => {
+ this.filesForGallery = this.getFilesForGallery(nextProps);
+ this.buildGalleryFiles().then((results) => {
this.galleryFiles = results;
});
}
@@ -72,20 +86,33 @@ export default class FileAttachmentList extends Component {
}
}
- loadFilesForPost = async () => {
- await this.props.actions.loadFilesForPostIfNecessary(this.props.postId);
- this.setState({
- loadingFiles: false,
- });
+ componentWillUnmount() {
+ this.mounted = false;
+ EventEmitter.off(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
+ Dimensions.removeEventListener('change', this.handleDimensions);
}
- buildGalleryFiles = async (props) => {
- const {files} = props;
+ attachmentIndex = (fileId) => {
+ return this.filesForGallery.findIndex((file) => file.id === fileId) || 0;
+ };
+
+ attachmentManifest = (attachments) => {
+ return attachments.reduce((info, file) => {
+ if (this.isImage(file)) {
+ info.imageAttachments.push(file);
+ } else {
+ info.nonImageAttachments.push(file);
+ }
+ return info;
+ }, {imageAttachments: [], nonImageAttachments: []});
+ };
+
+ buildGalleryFiles = async () => {
const results = [];
- if (files && files.length) {
- for (let i = 0; i < files.length; i++) {
- const file = files[i];
+ if (this.filesForGallery && this.filesForGallery.length) {
+ for (let i = 0; i < this.filesForGallery.length; i++) {
+ const file = this.filesForGallery[i];
const caption = file.name;
if (isDocument(file) || isVideo(file) || (!file.has_preview_image && !isGif(file))) {
@@ -116,16 +143,141 @@ export default class FileAttachmentList extends Component {
return results;
};
+ getFilesForGallery = (props) => {
+ const manifest = this.attachmentManifest(props.files);
+ const files = manifest.imageAttachments.concat(manifest.nonImageAttachments);
+ const results = [];
+
+ if (files && files.length) {
+ files.forEach((file) => {
+ results.push(file);
+ });
+ }
+
+ return results;
+ };
+
+ getPortraitPostWidth = () => {
+ const {isReplyPost} = this.props;
+ const {width, height} = Dimensions.get('window');
+ const permanentSidebar = DeviceTypes.IS_TABLET && !this.state?.isSplitView && this.state?.permanentSidebar;
+ let portraitPostWidth = Math.min(width, height) - VIEWPORT_IMAGE_OFFSET;
+
+ if (permanentSidebar) {
+ portraitPostWidth -= TABLET_WIDTH;
+ }
+
+ if (isReplyPost) {
+ portraitPostWidth -= VIEWPORT_IMAGE_REPLY_OFFSET;
+ }
+
+ return portraitPostWidth;
+ };
+
handleCaptureRef = (ref, idx) => {
this.items[idx] = ref;
};
+ handleDimensions = () => {
+ if (this.mounted) {
+ if (DeviceTypes.IS_TABLET) {
+ mattermostManaged.isRunningInSplitView().then((result) => {
+ const isSplitView = Boolean(result.isSplitView);
+ this.setState({isSplitView});
+ });
+ }
+ }
+ };
+
+ handlePermanentSidebar = async () => {
+ if (DeviceTypes.IS_TABLET && this.mounted) {
+ const enabled = await AsyncStorage.getItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS);
+ this.setState({permanentSidebar: enabled === 'true'});
+ }
+ };
+
handlePreviewPress = preventDoubleTap((idx) => {
previewImageAtIndex(this.items, idx, this.galleryFiles);
});
- renderItems = () => {
- const {canDownloadFiles, fileIds, files} = this.props;
+ isImage = (file) => (file.has_preview_image || isGif(file));
+
+ isSingleImage = (files) => (files.length === 1 && this.isImage(files[0]));
+
+ loadFilesForPost = async () => {
+ await this.props.actions.loadFilesForPostIfNecessary(this.props.postId);
+ this.setState({
+ loadingFiles: false,
+ });
+ }
+
+ renderItems = (items, moreImagesCount, includeGutter = false) => {
+ const {canDownloadFiles, onLongPress, theme} = this.props;
+ const isSingleImage = this.isSingleImage(items);
+ let nonVisibleImagesCount;
+ let container = styles.container;
+ const containerWithGutter = [container, styles.gutter];
+
+ return items.map((file, idx) => {
+ const f = {
+ caption: file.name,
+ data: file,
+ };
+
+ if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) {
+ nonVisibleImagesCount = moreImagesCount;
+ }
+
+ if (idx !== 0 && includeGutter) {
+ container = containerWithGutter;
+ }
+
+ return (
+
+
+
+ );
+ });
+ };
+
+ renderImageRow = (images) => {
+ if (images.length === 0) {
+ return null;
+ }
+
+ const visibleImages = images.slice(0, MAX_VISIBLE_ROW_IMAGES);
+ const {portraitPostWidth} = this.state;
+
+ let nonVisibleImagesCount;
+ if (images.length > MAX_VISIBLE_ROW_IMAGES) {
+ nonVisibleImagesCount = images.length - MAX_VISIBLE_ROW_IMAGES;
+ }
+
+ return (
+
+ { this.renderItems(visibleImages, nonVisibleImagesCount, true) }
+
+ );
+ };
+
+ render() {
+ const {canDownloadFiles, fileIds, files, isFailed} = this.props;
if (!files.length && fileIds.length > 0) {
return fileIds.map((id, idx) => (
@@ -140,45 +292,29 @@ export default class FileAttachmentList extends Component {
));
}
- return files.map((file, idx) => {
- const f = {
- caption: file.name,
- data: file,
- };
-
- return (
-
- );
- });
- };
-
- render() {
- const {fileIds, isFailed} = this.props;
+ const manifest = this.attachmentManifest(files);
return (
- 1}
- style={[(isFailed && styles.failed)]}
- keyboardShouldPersistTaps={'always'}
- >
- {this.renderItems()}
-
+
+ {this.renderImageRow(manifest.imageAttachments)}
+ {this.renderItems(manifest.nonImageAttachments)}
+
);
}
}
const styles = StyleSheet.create({
+ row: {
+ flex: 1,
+ flexDirection: 'row',
+ marginTop: 5,
+ },
+ container: {
+ flex: 1,
+ },
+ gutter: {
+ marginLeft: 8,
+ },
failed: {
opacity: 0.5,
},
diff --git a/app/components/file_attachment_list/file_attachment_list.test.js b/app/components/file_attachment_list/file_attachment_list.test.js
index 245e0bac1..69dcb609d 100644
--- a/app/components/file_attachment_list/file_attachment_list.test.js
+++ b/app/components/file_attachment_list/file_attachment_list.test.js
@@ -10,9 +10,50 @@ jest.mock('react-native-doc-viewer', () => ({
openDoc: jest.fn(),
}));
-describe('PostAttachmentOpenGraph', () => {
+describe('FileAttachmentList', () => {
const loadFilesForPostIfNecessary = jest.fn().mockImplementationOnce(() => Promise.resolve({data: {}}));
+ const files = [{
+ create_at: 1546893090093,
+ delete_at: 0,
+ extension: 'png',
+ has_preview_image: true,
+ height: 171,
+ id: 'fileId',
+ mime_type: 'image/png',
+ name: 'image01.png',
+ post_id: 'postId',
+ size: 14894,
+ update_at: 1546893090093,
+ user_id: 'userId',
+ width: 425,
+ },
+ {
+ create_at: 1546893090093,
+ delete_at: 0,
+ extension: 'png',
+ has_preview_image: true,
+ height: 800,
+ id: 'otherFileId',
+ mime_type: 'image/png',
+ name: 'image02.png',
+ post_id: 'postId',
+ size: 24894,
+ update_at: 1546893090093,
+ user_id: 'userId',
+ width: 555,
+ }];
+
+ const nonImage = {
+ extension: 'other',
+ id: 'fileId',
+ mime_type: 'other/type',
+ name: 'file01.other',
+ post_id: 'postId',
+ size: 14894,
+ user_id: 'userId',
+ };
+
const baseProps = {
actions: {
loadFilesForPostIfNecessary,
@@ -21,21 +62,7 @@ describe('PostAttachmentOpenGraph', () => {
deviceHeight: 680,
deviceWidth: 660,
fileIds: ['fileId'],
- files: [{
- create_at: 1546893090093,
- delete_at: 0,
- extension: 'png',
- has_preview_image: true,
- height: 171,
- id: 'fileId',
- mime_type: 'image/png',
- name: 'image.png',
- post_id: 'postId',
- size: 14894,
- update_at: 1546893090093,
- user_id: 'userId',
- width: 425,
- }],
+ files: [files[0]],
postId: 'postId',
theme: Preferences.THEMES.default,
};
@@ -48,6 +75,93 @@ describe('PostAttachmentOpenGraph', () => {
expect(wrapper.getElement()).toMatchSnapshot();
});
+ test('should match snapshot with two image files', () => {
+ const props = {
+ ...baseProps,
+ files,
+ };
+
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
+ test('should match snapshot with three image files', () => {
+ const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
+ const props = {
+ ...baseProps,
+ files: [...files, thirdImage],
+ };
+
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
+ test('should match snapshot with four image files', () => {
+ const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
+ const fourthImage = {...files[1], id: 'fourthFileId', name: 'image04.png'};
+
+ const props = {
+ ...baseProps,
+ files: [...files, thirdImage, fourthImage],
+ };
+
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
+ test('should match snapshot with more than four image files', () => {
+ const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
+ const fourthImage = {...files[1], id: 'fourthFileId', name: 'image04.png'};
+ const fifthImage = {...files[1], id: 'fifthFileId', name: 'image05.png'};
+ const sixthImage = {...files[1], id: 'sixthFileId', name: 'image06.png'};
+
+ const props = {
+ ...baseProps,
+ files: [...files, thirdImage, fourthImage, fifthImage, sixthImage],
+ };
+
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
+ test('should match snapshot with non-image attachment', () => {
+ const props = {
+ ...baseProps,
+ files: [nonImage],
+ };
+
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
+ test('should match snapshot with combination of image and non-image attachments', () => {
+ const props = {
+ ...baseProps,
+ files: [...files, nonImage],
+ };
+
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
test('should call loadFilesForPostIfNecessary when files does not exist', async () => {
const props = {
...baseProps,
diff --git a/app/components/file_upload_preview/file_upload_item/file_upload_item.js b/app/components/file_upload_preview/file_upload_item/file_upload_item.js
index ff33c5243..0304b61c2 100644
--- a/app/components/file_upload_preview/file_upload_item/file_upload_item.js
+++ b/app/components/file_upload_preview/file_upload_item/file_upload_item.js
@@ -190,11 +190,7 @@ export default class FileUploadItem extends PureComponent {
filePreviewComponent = (
);
} else {
@@ -202,8 +198,6 @@ export default class FileUploadItem extends PureComponent {
@@ -260,6 +254,7 @@ const styles = StyleSheet.create({
height: 100,
width: 100,
elevation: 10,
+ borderRadius: 5,
...Platform.select({
ios: {
backgroundColor: '#fff',
diff --git a/app/components/load_more_posts/load_more_posts.js b/app/components/load_more_posts/load_more_posts.js
index 15e7c94b1..6578b4489 100644
--- a/app/components/load_more_posts/load_more_posts.js
+++ b/app/components/load_more_posts/load_more_posts.js
@@ -46,7 +46,7 @@ export default class LoadMorePosts extends PureComponent {
return (
-
+
);
}
diff --git a/app/components/message_attachments/action_button/action_button.js b/app/components/message_attachments/action_button/action_button.js
index a604f6995..760a101d8 100644
--- a/app/components/message_attachments/action_button/action_button.js
+++ b/app/components/message_attachments/action_button/action_button.js
@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
import Button from 'react-native-button';
import {preventDoubleTap} from 'app/utils/tap';
-import {makeStyleSheetFromTheme} from 'app/utils/theme';
+import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import ActionButtonText from './action_button_text';
export default class ActionButton extends PureComponent {
@@ -19,6 +19,7 @@ export default class ActionButton extends PureComponent {
postId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
cookie: PropTypes.string.isRequired,
+ disabled: PropTypes.bool,
};
handleActionPress = preventDoubleTap(() => {
@@ -27,13 +28,15 @@ export default class ActionButton extends PureComponent {
}, 4000);
render() {
- const {name, theme} = this.props;
+ const {name, theme, disabled} = this.props;
const style = getStyleSheet(theme);
return (