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 (