[MM-10813] Design update for Post attachments (#3511)

* [MM-10813] Design update for Post attachments

Fixes https://github.com/mattermost/mattermost-server/issues/12841

* Updated visual treatment for Post attachments (images, documents, videos)
* Grouped image attachments, expanding to fit Post width dimension
* Itemized listing of non-image attachments
* Special handling of "small" image attachments (<48 point width/height)

* Set attachment post width to max width of portrait orientation

Accounts for post display offset and extra spacing used for rendering post replies.

* Use available Post real estate: flex: 1 instead of width 100%

* Image spacing responsibility: AttachmentList -> AttachmentImage

* Fit download progress circle correctly over new attachment icons

* Layers progress circle over the icon, rather than under.
* Uses offset constant as far as possible, rather than fixed point spaces.

* Refactor props and 'more' counting for image file attachment(s)

* Implement conditional gutter between image attachments

Flex's `justifyContent: space-between` won't work in this case because of the use of `absoluteFill` and `paddingBottom: 100%` in the box placeholder for an image attachment to auto-fill *all* available flex space.

* Additional snapshots for Post file attachment scenarios

* Use new 'text' icon for text files (.txt, .rtf)

Depends on https://github.com/mattermost/mattermost-redux/pull/979

Even without the change to mattermost-redux above, text files will default to pre-existing "code" icon.

* Set file attachment icon background to theme

Default to transparent. Override if explicitly specified.

* Treat animated GIFs as images when auto-adjusting attachment width

* Fix images layout, progressive image margins, and gallery for images and videos

* fix on iPad (splitview, permanent sidebar) card types and image sizes

* Add all files back to the gallery
This commit is contained in:
Amit Uttam
2019-11-16 00:41:09 -03:00
committed by Elias Nahum
parent 82d4d88ea5
commit 66a6e5c932
26 changed files with 2081 additions and 391 deletions

View File

@@ -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,
},
]
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
type="opacity"
>
<FileAttachmentIcon
backgroundColor="#fff"
file={
Object {
"mime_type": "image/png",
}
}
iconHeight={60}
iconWidth={60}
onCaptureRef={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
wrapperHeight={80}
wrapperWidth={80}
/>
</TouchableWithFeedbackIOS>
<TouchableWithFeedbackIOS
onPress={[Function]}
<View
style={
Object {
"borderLeftColor": "rgba(61,60,64,0.2)",
"borderLeftWidth": 1,
"flex": 1,
"paddingHorizontal": 8,
"paddingVertical": 5,
"marginHorizontal": 20,
"marginVertical": 10,
}
}
type="opacity"
/>
>
<TouchableWithFeedbackIOS
onPress={[Function]}
type="opacity"
>
<FileAttachmentIcon
file={
Object {
"mime_type": "image/png",
}
}
iconHeight={48}
iconWidth={36}
onCaptureRef={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
wrapperHeight={48}
wrapperWidth={36}
/>
</TouchableWithFeedbackIOS>
</View>
</View>
`;

View File

@@ -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 (
<View style={style.attachmentContainer}>
<Text
numberOfLines={2}
ellipsizeMode='tail'
style={style.fileName}
>
{file.caption.trim()}
</Text>
<View style={style.fileDownloadContainer}>
<TouchableWithFeedback
onPress={this.handlePreviewPress}
onLongPress={onLongPress}
type={'opacity'}
style={style.attachmentContainer}
>
<React.Fragment>
<Text
numberOfLines={2}
numberOfLines={1}
ellipsizeMode='tail'
style={style.fileInfo}
style={style.fileName}
>
{`${data.extension.toUpperCase()} ${Utils.getFormattedFileSize(data)}`}
{file.caption.trim()}
</Text>
</View>
</View>
<View style={style.fileDownloadContainer}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={style.fileInfo}
>
{`${Utils.getFormattedFileSize(data)}`}
</Text>
</View>
</React.Fragment>
</TouchableWithFeedback>
);
}
@@ -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 (
<View style={style.moreImagesWrapper}>
<Text style={style.moreImagesText}>
{`+${value}`}
</Text>
</View>
);
};
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 = (
<TouchableWithFeedback
key={`${this.props.id}${file.loading}`}
onPress={this.handlePreviewPress}
onLongPress={onLongPress}
type={'opacity'}
style={{width: imageDimensions?.width}}
>
<FileAttachmentImage
file={data || {}}
onCaptureRef={this.handleCaptureRef}
theme={theme}
isSingleImage={isSingleImage}
imageDimensions={imageDimensions}
/>
{this.renderMoreImagesOverlay(nonVisibleImagesCount, theme)}
</TouchableWithFeedback>
);
} else if (isDocument(data)) {
fileAttachmentComponent = (
<FileAttachmentDocument
ref={this.setDocumentRef}
canDownloadFiles={canDownloadFiles}
file={file}
onLongPress={onLongPress}
theme={theme}
/>
<View style={[style.fileWrapper]}>
<View style={style.iconWrapper}>
<FileAttachmentDocument
ref={this.setDocumentRef}
canDownloadFiles={canDownloadFiles}
file={file}
onLongPress={onLongPress}
theme={theme}
/>
</View>
{this.renderFileInfo()}
</View>
);
} else {
fileAttachmentComponent = (
<TouchableWithFeedback
onPress={this.handlePreviewPress}
onLongPress={onLongPress}
type={'opacity'}
>
<FileAttachmentIcon
file={data}
onCaptureRef={this.handleCaptureRef}
theme={theme}
/>
</TouchableWithFeedback>
<View style={[style.fileWrapper]}>
<View style={style.iconWrapper}>
<TouchableWithFeedback
onPress={this.handlePreviewPress}
onLongPress={onLongPress}
type={'opacity'}
>
<FileAttachmentIcon
file={data}
onCaptureRef={this.handleCaptureRef}
theme={theme}
/>
</TouchableWithFeedback>
</View>
{this.renderFileInfo()}
</View>
);
}
return (
<View style={[style.fileWrapper]}>
{fileAttachmentComponent}
<TouchableWithFeedback
style={style.fileInfoContainer}
onLongPress={onLongPress}
onPress={this.handlePreviewPress}
type={'opacity'}
>
{this.renderFileInfo()}
</TouchableWithFeedback>
</View>
);
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',
},
};
});

View File

@@ -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 (
<View style={[style.circularProgressContent, {width: wrapperWidth}]}>
{this.renderFileAttachmentIcon()}
</View>
);
};
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 (
<FileAttachmentIcon
@@ -354,29 +338,39 @@ export default class FileAttachmentDocument extends PureComponent {
theme={theme}
iconHeight={iconHeight}
iconWidth={iconWidth}
wrapperHeight={height}
wrapperWidth={width}
wrapperHeight={wrapperHeight}
wrapperWidth={wrapperWidth}
/>
);
}
render() {
const {onLongPress, theme, wrapperHeight} = this.props;
const {downloading, progress} = this.state;
renderDownloadProgres = () => {
const {theme} = this.props;
return (
<Text style={{fontSize: 10, color: theme.centerChannelColor, fontWeight: '600'}}>
{`${this.state.progress}%`}
</Text>
);
};
render() {
const {onLongPress, theme} = this.props;
const {downloading, progress} = this.state;
let fileAttachmentComponent;
if (downloading) {
fileAttachmentComponent = (
<CircularProgress
size={wrapperHeight}
fill={progress}
width={circularProgressWidth}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.5)}
tintColor={theme.linkColor}
rotation={0}
>
{this.renderProgress}
</CircularProgress>
<View style={[style.circularProgressContent]}>
<CircularProgress
size={40}
fill={progress}
width={circularProgressWidth}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.5)}
tintColor={theme.linkColor}
rotation={0}
>
{this.renderDownloadProgres}
</CircularProgress>
</View>
);
} 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,
},
});

View File

@@ -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 (
<View
ref={this.handleCaptureRef}
style={[styles.fileIconWrapper, {backgroundColor, height: wrapperHeight, width: wrapperWidth}]}
style={[styles.fileIconWrapper, {backgroundColor: bgColor, height: wrapperHeight, width: wrapperWidth}]}
>
<Image
style={[styles.icon, {height: iconHeight, width: iconWidth}]}
style={{maxHeight: iconHeight, maxWidth: iconWidth, tintColor: changeOpacity(theme.centerChannelColor, 20)}}
source={source}
/>
</View>
@@ -89,12 +95,5 @@ const styles = StyleSheet.create({
fileIconWrapper: {
alignItems: 'center',
justifyContent: 'center',
borderTopLeftRadius: 2,
borderBottomLeftRadius: 2,
},
icon: {
borderTopLeftRadius: 2,
borderBottomLeftRadius: 2,
backgroundColor: '#fff',
},
});

View File

@@ -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 (<View style={style.boxPlaceholder}/>);
};
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 (
<View
ref={this.handleCaptureRef}
style={[style.fileImageWrapper, {height: wrapperHeight, width: wrapperWidth, overflow: 'hidden'}]}
style={[
wrapperStyle,
style.smallImageBorder,
{borderColor: changeOpacity(theme.centerChannelColor, 0.4)},
]}
>
{this.boxPlaceholder()}
<View style={style.smallImageOverlay}>
<ProgressiveImage
style={{height: file.height, width: file.width}}
defaultSource={thumb}
tintDefaultSource={!file.localPath}
filename={file.name}
resizeMode={'contain'}
resizeMethod={resizeMethod}
{...this.imageProps(file)}
/>
</View>
</View>
);
};
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 (
<View
ref={this.handleCaptureRef}
style={style.fileImageWrapper}
>
{this.boxPlaceholder()}
<ProgressiveImage
style={imageStyle}
style={[this.props.isSingleImage ? null : style.imagePreview, imageDimensions]}
defaultSource={thumb}
tintDefaultSource={!file.localPath}
filename={file.name}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
{...imageProps}
{...this.imageProps(file)}
/>
</View>
);
@@ -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',
},
});

View File

@@ -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 (
<View
style={container}
key={file.id}
>
<FileAttachment
key={file.id}
canDownloadFiles={canDownloadFiles}
file={f}
id={file.id}
index={this.attachmentIndex(file.id)}
onCaptureRef={this.handleCaptureRef}
onPreviewPress={this.handlePreviewPress}
onLongPress={onLongPress}
theme={theme}
isSingleImage={isSingleImage}
nonVisibleImagesCount={nonVisibleImagesCount}
wrapperWidth={this.getPortraitPostWidth()}
/>
</View>
);
});
};
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 (
<View style={[styles.row, {width: portraitPostWidth}]}>
{ this.renderItems(visibleImages, nonVisibleImagesCount, true) }
</View>
);
};
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 (
<FileAttachment
key={file.id}
canDownloadFiles={canDownloadFiles}
file={f}
id={file.id}
index={idx}
onCaptureRef={this.handleCaptureRef}
onPreviewPress={this.handlePreviewPress}
onLongPress={this.props.onLongPress}
theme={this.props.theme}
/>
);
});
};
render() {
const {fileIds, isFailed} = this.props;
const manifest = this.attachmentManifest(files);
return (
<ScrollView
horizontal={true}
scrollEnabled={fileIds.length > 1}
style={[(isFailed && styles.failed)]}
keyboardShouldPersistTaps={'always'}
>
{this.renderItems()}
</ScrollView>
<View style={[isFailed && styles.failed]}>
{this.renderImageRow(manifest.imageAttachments)}
{this.renderItems(manifest.nonImageAttachments)}
</View>
);
}
}
const styles = StyleSheet.create({
row: {
flex: 1,
flexDirection: 'row',
marginTop: 5,
},
container: {
flex: 1,
},
gutter: {
marginLeft: 8,
},
failed: {
opacity: 0.5,
},

View File

@@ -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(
<FileAttachment {...props}/>
);
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(
<FileAttachment {...props}/>
);
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(
<FileAttachment {...props}/>
);
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(
<FileAttachment {...props}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with non-image attachment', () => {
const props = {
...baseProps,
files: [nonImage],
};
const wrapper = shallow(
<FileAttachment {...props}/>
);
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(
<FileAttachment {...props}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should call loadFilesForPostIfNecessary when files does not exist', async () => {
const props = {
...baseProps,

View File

@@ -190,11 +190,7 @@ export default class FileUploadItem extends PureComponent {
filePreviewComponent = (
<FileAttachmentImage
file={file}
imageSize='fullsize'
imageHeight={100}
imageWidth={100}
wrapperHeight={100}
wrapperWidth={100}
theme={theme}
/>
);
} else {
@@ -202,8 +198,6 @@ export default class FileUploadItem extends PureComponent {
<FileAttachmentIcon
file={file}
theme={theme}
imageHeight={100}
imageWidth={100}
wrapperHeight={100}
wrapperWidth={100}
/>
@@ -260,6 +254,7 @@ const styles = StyleSheet.create({
height: 100,
width: 100,
elevation: 10,
borderRadius: 5,
...Platform.select({
ios: {
backgroundColor: '#fff',

View File

@@ -32,6 +32,14 @@ exports[`AttachmentImage it matches snapshot 1`] = `
}
>
<Connect(ProgressiveImage)
imageStyle={
Object {
"marginBottom": 5,
"marginLeft": 2.5,
"marginRight": 5,
"marginTop": 2.5,
}
}
imageUri="https://images.com/image.png"
resizeMode="contain"
style={

View File

@@ -141,6 +141,7 @@ export default class AttachmentImage extends PureComponent {
progressiveImage = (
<ProgressiveImage
ref={this.setImageRef}
imageStyle={style.attachmentMargin}
style={{height, width}}
imageUri={imageUri}
resizeMode='contain'
@@ -178,5 +179,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
borderRadius: 2,
flex: 1,
},
attachmentMargin: {
marginTop: 2.5,
marginLeft: 2.5,
marginBottom: 5,
marginRight: 5,
},
};
});

View File

@@ -247,6 +247,7 @@ export default class PostBody extends PureComponent {
isFailed={isFailed}
onLongPress={this.showPostOptions}
postId={post.id}
isReplyPost={this.props.isReplyPost}
/>
);
}

View File

@@ -7,7 +7,7 @@ import {Animated, Image, ImageBackground, Platform, View, StyleSheet} from 'reac
import CustomPropTypes from 'app/constants/custom_prop_types';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {changeOpacity} from 'app/utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const AnimatedImageBackground = Animated.createAnimatedComponent(ImageBackground);
@@ -18,6 +18,7 @@ export default class ProgressiveImage extends PureComponent {
defaultSource: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), // this should be provided by the component
filename: PropTypes.string,
imageUri: PropTypes.string,
imageStyle: CustomPropTypes.Style,
onError: PropTypes.func,
resizeMethod: PropTypes.string,
resizeMode: PropTypes.string,
@@ -96,7 +97,17 @@ export default class ProgressiveImage extends PureComponent {
};
render() {
const {style, defaultSource, isBackgroundImage, theme, tintDefaultSource, onError, resizeMode, resizeMethod} = this.props;
const {
defaultSource,
imageStyle,
isBackgroundImage,
onError,
resizeMode,
resizeMethod,
style,
theme,
tintDefaultSource,
} = this.props;
const {uri, intensity, thumb} = this.state;
const hasDefaultSource = Boolean(defaultSource);
const hasPreview = Boolean(thumb);
@@ -117,13 +128,15 @@ export default class ProgressiveImage extends PureComponent {
ImageComponent = Animated.Image;
}
const styles = getStyleSheet(theme);
let defaultImage;
if (hasDefaultSource && tintDefaultSource) {
defaultImage = (
<View style={styles.defaultImageContainer}>
<DefaultComponent
source={defaultSource}
style={{flex: 1, tintColor: changeOpacity(theme.centerChannelColor, 0.2)}}
style={styles.defaultImageTint}
resizeMode='center'
resizeMethod={resizeMethod}
onError={onError}
@@ -139,7 +152,7 @@ export default class ProgressiveImage extends PureComponent {
resizeMethod={resizeMethod}
onError={onError}
source={defaultSource}
style={StyleSheet.absoluteFill}
style={[StyleSheet.absoluteFill, imageStyle]}
>
{this.props.children}
</DefaultComponent>
@@ -155,7 +168,7 @@ export default class ProgressiveImage extends PureComponent {
resizeMethod={resizeMethod}
onError={onError}
source={{uri: thumb}}
style={StyleSheet.absoluteFill}
style={[StyleSheet.absoluteFill, imageStyle]}
blurRadius={5}
>
{this.props.children}
@@ -167,7 +180,7 @@ export default class ProgressiveImage extends PureComponent {
resizeMethod={resizeMethod}
onError={onError}
source={{uri}}
style={[StyleSheet.absoluteFill, styles.attachmentMargin]}
style={[StyleSheet.absoluteFill, imageStyle]}
>
{this.props.children}
</ImageComponent>
@@ -180,19 +193,19 @@ export default class ProgressiveImage extends PureComponent {
}
}
const styles = StyleSheet.create({
defaultImageContainer: {
flex: 1,
position: 'absolute',
height: 80,
width: 80,
alignItems: 'center',
justifyContent: 'center',
},
attachmentMargin: {
marginTop: 2.5,
marginLeft: 2.5,
marginBottom: 5,
marginRight: 5,
},
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
defaultImageContainer: {
flex: 1,
position: 'absolute',
height: 80,
width: 80,
alignItems: 'center',
justifyContent: 'center',
},
defaultImageTint: {
flex: 1,
tintColor: changeOpacity(theme.centerChannelColor, 0.2),
},
};
});

View File

@@ -2,3 +2,5 @@
// See LICENSE.txt for license information.
export const MAX_ATTACHMENT_FOOTER_LENGTH = 300;
export const ATTACHMENT_ICON_HEIGHT = 48;
export const ATTACHMENT_ICON_WIDTH = 36;

View File

@@ -206,8 +206,8 @@ export default class ImagePreview extends PureComponent {
backgroundColor='transparent'
canDownloadFiles={canDownloadFiles}
file={file}
iconHeight={100}
iconWidth={100}
iconHeight={200}
iconWidth={200}
theme={theme}
wrapperHeight={200}
wrapperWidth={200}
@@ -223,8 +223,8 @@ export default class ImagePreview extends PureComponent {
backgroundColor='transparent'
file={file}
theme={this.props.theme}
iconHeight={150}
iconWidth={150}
iconHeight={200}
iconWidth={200}
wrapperHeight={200}
wrapperWidth={200}
/>
@@ -366,7 +366,7 @@ export default class ImagePreview extends PureComponent {
if (imageDimensions) {
const {deviceHeight, deviceWidth} = this.props;
const {height, width} = imageDimensions;
const {style, ...otherProps} = imageProps;
const {style, source} = imageProps;
const statusBar = DeviceTypes.IS_IPHONE_WITH_INSETS ? 0 : 20;
const flattenStyle = StyleSheet.flatten(style);
const calculatedDimensions = calculateDimensions(height, width, deviceWidth, deviceHeight - statusBar);
@@ -375,7 +375,7 @@ export default class ImagePreview extends PureComponent {
return (
<View style={[style, {justifyContent: 'center', alignItems: 'center'}]}>
<Image
{...otherProps}
source={source}
style={imageStyle}
/>
</View>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
assets/base/images/icons/excel.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 B

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
assets/base/images/icons/patch.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
assets/base/images/icons/ppt.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
assets/base/images/icons/word.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 B

After

Width:  |  Height:  |  Size: 2.4 KiB