From 66a6e5c93250cccc0296695f36a23cccebca5612 Mon Sep 17 00:00:00 2001 From: Amit Uttam Date: Sat, 16 Nov 2019 00:41:09 -0300 Subject: [PATCH] [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 --- .../file_attachment.test.js.snap | 113 +- .../file_attachment_list.test.js.snap | 1442 ++++++++++++++++- .../file_attachment_list/file_attachment.js | 186 ++- .../file_attachment_document.js | 80 +- .../file_attachment_icon.js | 29 +- .../file_attachment_image.js | 138 +- .../file_attachment_list.js | 244 ++- .../file_attachment_list.test.js | 146 +- .../file_upload_item/file_upload_item.js | 9 +- .../__snapshots__/index.test.js.snap | 8 + .../attachment_image/index.js | 7 + app/components/post_body/post_body.js | 1 + .../progressive_image/progressive_image.js | 55 +- app/constants/attachment.js | 2 + app/screens/image_preview/image_preview.js | 12 +- assets/base/images/icons/audio.png | Bin 1691 -> 2282 bytes assets/base/images/icons/code.png | Bin 1382 -> 2685 bytes assets/base/images/icons/excel.png | Bin 472 -> 2175 bytes assets/base/images/icons/generic.png | Bin 2256 -> 2644 bytes assets/base/images/icons/image.png | Bin 1006 -> 2605 bytes assets/base/images/icons/patch.png | Bin 1504 -> 2668 bytes assets/base/images/icons/pdf.png | Bin 2289 -> 2482 bytes assets/base/images/icons/ppt.png | Bin 2015 -> 1570 bytes assets/base/images/icons/text.png | Bin 0 -> 943 bytes assets/base/images/icons/video.png | Bin 659 -> 1605 bytes assets/base/images/icons/word.png | Bin 615 -> 2441 bytes 26 files changed, 2081 insertions(+), 391 deletions(-) mode change 100644 => 100755 assets/base/images/icons/excel.png mode change 100644 => 100755 assets/base/images/icons/patch.png mode change 100644 => 100755 assets/base/images/icons/ppt.png create mode 100644 assets/base/images/icons/text.png mode change 100644 => 100755 assets/base/images/icons/word.png 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/message_attachments/attachment_image/__snapshots__/index.test.js.snap b/app/components/message_attachments/attachment_image/__snapshots__/index.test.js.snap index c941946d9..e4b92da1f 100644 --- a/app/components/message_attachments/attachment_image/__snapshots__/index.test.js.snap +++ b/app/components/message_attachments/attachment_image/__snapshots__/index.test.js.snap @@ -32,6 +32,14 @@ exports[`AttachmentImage it matches snapshot 1`] = ` } > { borderRadius: 2, flex: 1, }, + attachmentMargin: { + marginTop: 2.5, + marginLeft: 2.5, + marginBottom: 5, + marginRight: 5, + }, }; }); diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index b49a69f92..0ddd542f0 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -247,6 +247,7 @@ export default class PostBody extends PureComponent { isFailed={isFailed} onLongPress={this.showPostOptions} postId={post.id} + isReplyPost={this.props.isReplyPost} /> ); } diff --git a/app/components/progressive_image/progressive_image.js b/app/components/progressive_image/progressive_image.js index 1b4d73bd7..68fb5f276 100644 --- a/app/components/progressive_image/progressive_image.js +++ b/app/components/progressive_image/progressive_image.js @@ -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 = ( {this.props.children} @@ -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} @@ -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), + }, + }; }); diff --git a/app/constants/attachment.js b/app/constants/attachment.js index 7f234bf52..532fcf90b 100644 --- a/app/constants/attachment.js +++ b/app/constants/attachment.js @@ -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; diff --git a/app/screens/image_preview/image_preview.js b/app/screens/image_preview/image_preview.js index 2f0214773..150e8d879 100644 --- a/app/screens/image_preview/image_preview.js +++ b/app/screens/image_preview/image_preview.js @@ -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 ( diff --git a/assets/base/images/icons/audio.png b/assets/base/images/icons/audio.png index 93cf6ef33ba799f82c80e69c6b17e8b91da33a03..dc4d3cb4901f6fc2feb400829816b7babf1767b2 100644 GIT binary patch literal 2282 zcmVPx-q)9|URCodHon34cRTO~lo!$O`#a3yTR%`iHVlm)PHKt;>3tFQAUWh+0Z4pQf zU^SB9^FD(?4HlB7U|Sv(jXnTsMCon|RAXuo0wD<2Qj3&r1FaM&?ao}!*>-mC?9T3N zXJ>cY-MI}BQGpGq&YVoGd z2S>-o&hU_^Abeuz6w`vEtLF(S)VII<1`)`Id~-}`n^NLV zL_0>dA&h&yjWD(1zmQJpjf_mDSo1YeLzsEad*mfPZ5=)gx#GSA| zR}#LgGUf7~NAE#Qt*yp=4*ZZG3OqFYetvDoPlGKL-ziJz*9-wfM(8)t(W=PZ2zoMV z5>9_>#cu^Qu82i(>UerQrMYv8FHFMm<%cuaE^yKd!6$9;S0ITE;Iw}*YJbm=DF!}Z zwZDVW2pk-(>`OPEoO8W<;14E}(?i2o|Fpf`Ue1@v5!iN^Zs)kT`QWr);q;h9LjXY! zEK&Dj3UIK_AyIVTMWnBO#!4hFYcJg3s`TmbUr)1R6ds*)$~qols@3s&EV5C6{C$Ai zg)>$QafJJ4$rPT#Y2`1VA;7b>XTd`52C^}$DAGeETTVP6$32oY`!J=nU)JHc1}%@% zb&JOMAijA@hWeWwZayj&8S3h>)pooL7luc{H1e=6_%(`c^tQMe#9gTW-dHwXP~-j< z#I2}94(m;?v=D@|J~f8_47Z>d`VzLNr={W)705b1gT%<|ybgZw60d_LBcPbWi~xkA z9B8gQ0eAX++=!QOG7&7Khu0;-7Vx@WdByEYA$$}<8`;Rg4!5|b47Ujijx^Mj_0D5e}-mj2DB>y@o~3x+MnNFINk5}c1& zg)7&a>``~VXw#|r-m&p^I5W+X>w?q1C;L82ldL~e*Ok$G_ttt@2Vdo7IbwG}*Do0k z2>x;_ydrUh?UE!7!%b^dwAVtCIKCWC>wY*mc#DO-*}Lz>n{pff+`F?5+MOrsa6CG& z*D=EBx<#X=m>6{rVD%7J5%JDGnlgEU)eU$16TiBJyMDn+?fiAtf(@>0CP_}dF8IMZ z{Px?JUEeNvx8^M>c#=}#X;|iX%=5XulW%E6qPxMup5WrHY+Gdi zk<()mjhSLOY`vhd+`*V&VN|O3FLxj$>a!$V?ua;u$=79_9tg?>%N;vl6(MjZkLiMC zn^AZ2Se+q&Cg=NJtT0l5X>|ml1VaM9GK@D6(S{}Um$HV3t~LUk8omrntxpFGTnoaw z4VhwmCnL$|m~nv&7s~wka7ROVedA9GC&04DS1_#*%sLhe-f2zwd}q{&75z-0s#J_4t__VS+R|I>8K3RoS_itm_9f&*> zk`RZ8q*;(W7KD_+$V+%JI01*&%W2c$m{bdr0bD!i=mNP^)~C}k=@vAdV5s%Q1f)iV zi!%XnFw{g7kQx;(&IH84P!ml+YE-y56A%YOO*8?iQQ_iDKpYG;(FCMMg^M!*aWK?G z*ngvn?GfKQ-qmuZL0s_Ikqy4PqgOF@d;xyChM=({8v(yaqTm5v2f!ELE8@~X@r~K{ zWrVn@| z)&aUQpihFW;LZ5a4M;434Z0jaG%fmA9Wug6jk;FtN;K207*qoM6N<$ Ef>~lP$p8QV literal 1691 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 literal 1382 zcmV-s1)2JZP)r&9L`0RBwO$^maiJUpK*X61mdCjhS?X61nY z3czh6X60}fBksNTiCH<|V+p`Ji&;6~*8^||s;3uM^oiX@{gC=B^^{GNVH$P+0O0o; z81n-4gX)b+g?os4*eECQjiiu~V>{yE`Lu}frd5A3%Ksg#-mP@F=^-P_Z3N(@$+4yb zTo{ncfP3gT;lghwfs8!=2jKqSI9wib8F1le66IN%$Z}gHFVp~e_j2LF!__k+$aET! zWxM$I(LCg`;KJ?HlP1XZZXid0^)%$ebNBKf1McvM$@f!llpxpi>VbhQuSblxy?T&& z$O+(Xk|^8GM4n?K$J`+j@{p<_J6z7z0Cxo< z*YyIWg1m>rttRK$M7GT;IL`JdAv;`T{Vb5Jk9sOmIbV9%q;IWaAP)s(hZ~dmVIt>z0vUqra3@uF3grA%pu!*{dDl;rW!tJCJ6z=dI*_-!dNP6RLQVj;Z_VJ&NqlAbtUz^!yjRsB zjx-AwbJ&l_eULyVAUj;l;|XGO_nb|+$+#t6Q{h@2bf9S1VKeOhXRgD225W(yJM!vZ3V+FG2{EgTHHA$v$u~0yNaZGNI zHQ$0vb56!^_a$CQn}m!TZFmm5WcuO?aI<^6#BazRte&X=xS2slv1q?Q?hmtk?+axJ z8Tq&5n{x_-Yx<*0WWR;TyKJ5xO%?!|XwGR>JX~{MA0qGKM8-P>%Hr$6{2?RjiA-~j z!?m`A2RYvf&w+sM{rI&PiH)=B1=K&Z@-NR~v=vkjdtp zO&o5$Aa4{g{^x=5da9>L@gYV!xb`7;R8LBbJF9wVg616Rq8tedz)iQn^Ox1ZnEMiA z9i0C1v^?BWAwOGDbIx$}ys6e<7Y&zy%wO6L#iCsTC-etNA^y^KfLrDz?f{nDu{47x{I;*(siEPgKT3luUZ$LaeUmz~CfUh##oZ}(@{~CbXo^Q@^ zS!~XESX>6hFRt4|TxJ2UX1O`X#czCTF&@Bgiz^$@OQ?@mzpU<}{#Lz&6t9u(!2t;X o004lX|F45~r~?21000002xfr}Q1mZg3;+NC07*qoM6N<$f`?S0R{#J2 diff --git a/assets/base/images/icons/excel.png b/assets/base/images/icons/excel.png old mode 100644 new mode 100755 index f9e9bc83a87c2c9d087ac85375204c209573a115..dfb83aceba672f57fd626ce1d682086610dcfc07 GIT binary patch literal 2175 zcmV-_2!QvAP)Px-IY~r8RCodHoqKE)MHs+mcH2811%(=}*CJv=#9$Cj)ISWyN}>sg_5nH67zi;C z%0ph#qYWVv5PK~zQz>sWz94uJS`sl31&o@Q7%&=%F*ZCZ^g+WzX|e5fcl>7Idi$7m z=yhlB+S~0PH}ieFGvD{?w==slv%45Muw}hh^gNN2YN%==M(8F%C=(F`$z<4YD;QEw zFgbxy`o5;pFVE?%5^khFRPeEi$n4|+qIch%#wx~WXJ)Qv<+)kmPO23O$QLR&i3oYY zY(}!@0n*9LT<>%WNfKqOue{*YK!ihi5Q3&%JKwpLriDb11_zNmT7h|!Bze+;;FLet z;dQE6mx%mB+h+J95j9U$NhKjQ0YoDQnzlTB;n2DfRYLO&vLw%3T5-71Xd=nzH=v#(hr($I9{tc*;lRuF3s7&7 zGZ^5|e`nl)m<{sX(`vB5i5pO=z=;b`D#7V9pj3m?CqStP=bixpoRh4Jz`@d$QxH9E z++OT19OID1AndK;0+i}-D6}3U$c1w!rkH?SII$DX0?36EgF{mWKrS3390Q=N^89b1 zD^J)!B zC@l8M1RZNAtNn&`oEazl<8&Bw1n|; z`UpWZFXC4yAZl=9ux2Ox{Ae+r5V=bw)iB-N>zte#A)5i_F!S*+&vv|_wDy2mZg*Zd zAaI1hRMLECceE$?3=Rkyl_C&Xld_-TYp&huD1^p$oL&$4sj$Lr4m9Y$D~bY<28eVUH1d~- zJaVm0rB4W;i52-Hu&UC#eEvpA zc6)md_hkH|`@Kg(0R2Ny1$5`S7m3{Q1A445bpMu7Mu@c8@T#3x`t5BT1vm~UJ5)3k zu8}PliU6m@gEAJt4ec7H9|Y~DFCby+1PUDmG@EUCnY_QPo{J+lw+;IuSBQ0MXIG zE<*Oh09d-&35k%+td;%4hiAZ@JM-xvpVF#!4~>}1vpeqypdZe6EQ3?)X|wS#I(Sj` zdM%@gbicZ#pZ|+w#Jm-O9`H4PMbQj%c_Q+{0r@rs9)__VCIlGPSOU{F|MlcpbFlUh zM9~SBo)C4XHuA_)+9(qa2q_)AA$ly+HqZzkX(~PX70;J+uV*nd;_c@BAd1GQk+vOX zx!rl8fMD8YE_CTHT8+l7ayq)FAq; zcen2)2#cV;o06PIxPUnS7Z;EVh(my*b|c6I#38^@yAk99;t=4d-3W34aR_kKZUpfG zVOWk5+vq#qW4Y8yS$)T?5{$Lf>oL$5;Oq0Lls5ZL*+}$@Bzg$+>i~(Ef708Kh&1(? z`mV2A`&vs@Xy#*tbp57Z)dge4m+1@e={w%7uQAsbG4Rv(^w|hl8@L^YFc%MeckS~4 zf}|Se>XvA|82y$F{alNE#SiR~C=1GjdZ7WL->{+IvZ2&i>zD_&yM3{qevJiJN5AVs zkA#NWxM^(?ip--Q-GJE$SgK1hjC%~WT<@RH`X9@kErkF8002ovPDHLkV1hLN B53v9M literal 472 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P3?%t>9eV(z%mREuT!FOCq!?0>*(>8IK%GJ* zL4Lsu4hjYT@1K7!u)jWFegAxe1mAqeC(9Wa80|e>978G?_grVqYjcojdDvL`{;z&? z{3riiz4f2n3M&d;JYKh4`p#dYqYBT<8Ds7U2er8#eXn({`bxs3xqsf-^}Tz*v*9$S zQc6T9--@&E^=@nl{rTdPan=EaL(9(kFq$Om*OY25klbD4%+6^N@6G36+WPF*>Ex`H z3w|2S>oCeVHqR;JP?(eOHi20imR@M_y-@XrF>$)ouMfTk3)fuGy!P~oxT)j&Hi22Y zzg!T?X?oTsX2mLN=D59kW2<~@aI5bHbKA}Ol3SP?mK{>dVEW+v%eFtd`R%`dis8q$ zwe?kWF}*2DioGZE`)y6mVJYk7pV^8Gj=lRo(abt0u9m$$uj}nAz2?KQ^RKz@F7;q# z;5i&nAolEjj_DGGCFM5nFYjSycyRoi_rrz)haLO}m<7ee*j{{gXlZR_{ADAMnD}rX ZBU9#+;ENkBx&s56!PC{xWt~$(696jw%qIW< diff --git a/assets/base/images/icons/generic.png b/assets/base/images/icons/generic.png index e89b90ff0b2bb8d52bb208b99c124dad18b7afad..d17636fcf5c6ad74188a0f2d47ce6bc82c40c634 100644 GIT binary patch literal 2644 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 literal 2289 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 diff --git a/assets/base/images/icons/text.png b/assets/base/images/icons/text.png new file mode 100644 index 0000000000000000000000000000000000000000..52cb3f1707fca7cb1c0de76f7f71ff64fa0b2645 GIT binary patch literal 943 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 literal 615 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P3?%t>9eV(z%mREuT!C~%(jHRLHmCe+K%GJ* zL4Lsu>;DHB)Zc$!(7#{7VgC690bhA>-75?XjORRE978G?lNK0Durgdb#T;HD(9zg7 zA?|(s&3Hknlm2%X&wIAv@TR3@YK>vmn@?O?zpnAY+Olu*>*biz-%fET_{?!_A(zkA z&6)>{lnz)m>Brp7UaooIEXTBis`h_xEn?AEUU7q`W^1wb!IKDuOQC$ z?AA|_gWJ!v#z)K=T%)G0Mdub->RTlkxGPP+fXW6Q-3;Vqt1YKos9x45WfZKkiH|K_XA=@ske z3A&w3&rx9#+4RD>Qn4~=_40-rp~7Y&D(}ta9$x(M&PAsQe;(93B;s83Z_gGM4Kvp5pG?BvaK$~n7{w^s;9nWy zvr)6cB`9av4VCvyFE3_nKCiz#@{X$N<(g^IdI#S;JG`fWbKRfHmy_