PLT-5530 Render placeholder icons + metadata for file attachments in posts list (#249)

* Add icons for file types

* Compress image files for file type icons

* Add constants for file types

* Add utils for file metadata

* Add actions & reducers for fetching file attachments

* Render file attachments in posts

* Add reducers for handling files requests

* Refactor getFileType in file utils

* Refactor getFileIconPath in file utils

* Refactor getFormattedFileSize in file utils

* Trim trailing whitespace in getTruncatedFilename

* Style file attachment metadata

* Remove entity store reducer for files fetch failure

* Change filesForPost to fileIdsForPost in files store

* Use a selector for getFilesForPost

* Memoize getFilesForPost selector

* Dispatch postId with getFilesForPost

* Add test for getFilesForPost

* Upload seed file in getFilesForPost test

* Display correct icon for image attachments

* Fix reducers for receiving files for post

Expect response data to be array not object

* Style attachment post

* Merge headers (defaults + opts) in Client.doFetch

* Upload file as FormData

* Use form-data lib instead of built-in polyfill

* Associate file with post in getFilesForPost test

* Improve assertions in getFilesForPost test

* Check for createPost failure in getFilesForPost test

* Use correct post id in getFilesForPost test

* Use client not action to create post in getFilesForPost test

* Fix file upload in getFilesForPost test

* Add assertion for name of uploaded file

* Update attachment post style

* Fix spelling of loadFilesForPostIfNecessary

* Remove eslint-disable no-magic-numbers from file_utils.js

* Remove unused index prop from FileAttachment

* Clarify code for merging headers in Client.getOptions

* Dynamically truncate file attachment names

* Extract component for FileAttachmentIcon

* Use white bg for FileAttachmentIcon to match webapp styles

* Match webapp styles for file attachment borders

* Move file icon path lookup to FileAttachmentIcon

* Use more logical ordering for file type lookup
This commit is contained in:
Chris Duarte 2017-02-17 14:40:40 -08:00 committed by Harrison Healey
parent 8aea409ae6
commit 3b7a6df617
30 changed files with 581 additions and 3 deletions

View file

@ -13,6 +13,7 @@ import {
selectChannel,
leaveChannel as serviceLeaveChannel} from 'service/actions/channels';
import {getPosts} from 'service/actions/posts';
import {getFilesForPost} from 'service/actions/files';
import {savePreferences, deletePreferences} from 'service/actions/preferences';
import {getTeamMembersByIds} from 'service/actions/teams';
import {Constants, UsersTypes} from 'service/constants';
@ -98,6 +99,18 @@ export function loadPostsIfNecessary(channel) {
};
}
export function loadFilesForPostIfNecessary(post) {
return async (dispatch, getState) => {
const {files, teams} = getState().entities;
const fileIdsForPost = files.fileIdsByPostId[post.id];
if (!fileIdsForPost) {
const teamId = teams.currentId;
await getFilesForPost(teamId, post.channel_id, post.id)(dispatch, getState);
}
};
}
export function selectInitialChannel(teamId) {
return async (dispatch, getState) => {
const state = getState();

View file

@ -0,0 +1,107 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {
Component,
PropTypes
} from 'react';
import {
Text,
View,
StyleSheet
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import * as Utils from 'service/utils/file_utils.js';
import FileAttachmentIcon from './file_attachment_icon';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
downloadIcon: {
color: changeOpacity(theme.centerChannelColor, 0.7),
marginRight: 5
},
fileDownloadContainer: {
flexDirection: 'row',
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)
},
fileName: {
flexDirection: 'column',
flexWrap: 'wrap',
marginLeft: 2,
fontSize: 14,
color: theme.centerChannelColor
},
fileWrapper: {
flex: 1,
flexDirection: 'row',
marginTop: 10,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2)
}
});
});
export default class FileAttachment extends Component {
static propTypes = {
file: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired
};
renderFileInfo() {
const {file, theme} = this.props;
const style = getStyleSheet(theme);
return (
<View style={style.fileInfoContainer}>
<Text
numberOfLines={4}
style={style.fileName}
>
{file.name.trim()}
</Text>
<View style={style.fileDownloadContainer}>
<Icon
name='download'
size={16}
style={style.downloadIcon}
/>
<Text style={style.fileInfo}>
{`${file.extension.toUpperCase()} ${Utils.getFormattedFileSize(file)}`}
</Text>
</View>
</View>
);
}
render() {
const {file, theme} = this.props;
const style = getStyleSheet(theme);
return (
<View style={style.fileWrapper}>
<FileAttachmentIcon
file={file}
theme={theme}
/>
{this.renderFileInfo()}
</View>
);
}
}

View file

@ -0,0 +1,76 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {
Component,
PropTypes
} from 'react';
import {
View,
Image,
StyleSheet
} from 'react-native';
import * as Utils from 'service/utils/file_utils';
import audioIcon from 'assets/images/icons/audio.png';
import codeIcon from 'assets/images/icons/code.png';
import excelIcon from 'assets/images/icons/excel.png';
import genericIcon from 'assets/images/icons/generic.png';
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 videoIcon from 'assets/images/icons/video.png';
import wordIcon from 'assets/images/icons/word.png';
const styles = StyleSheet.create({
fileIcon: {
width: 60,
height: 60
},
fileIconWrapper: {
alignItems: 'center',
justifyContent: 'center',
width: 100,
height: 100,
backgroundColor: '#fff'
}
});
const ICON_PATH_FROM_FILE_TYPE = {
audio: audioIcon,
code: codeIcon,
image: imageIcon,
other: genericIcon,
patch: patchIcon,
pdf: pdfIcon,
presentation: pptIcon,
spreadsheet: excelIcon,
video: videoIcon,
word: wordIcon
};
export default class FileAttachmentIcon extends Component {
static propTypes = {
file: PropTypes.object.isRequired
};
getFileIconPath(file) {
const fileType = Utils.getFileType(file);
return ICON_PATH_FROM_FILE_TYPE[fileType] || ICON_PATH_FROM_FILE_TYPE.other;
}
render() {
const {file} = this.props;
return (
<View style={styles.fileIconWrapper}>
<Image
style={styles.fileIcon}
source={this.getFileIconPath(file)}
/>
</View>
);
}
}

View file

@ -0,0 +1,43 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {
Component,
PropTypes
} from 'react';
import {
View
} from 'react-native';
import FileAttachment from './file_attachment';
export default class FileAttachmentList extends Component {
static propTypes = {
actions: PropTypes.object.isRequired,
post: PropTypes.object.isRequired,
files: PropTypes.array.isRequired,
theme: PropTypes.object.isRequired
};
componentDidMount() {
const {post} = this.props;
this.props.actions.loadFilesForPostIfNecessary(post);
}
render() {
const fileAttachments = this.props.files.map((file) => (
<FileAttachment
key={file.id}
file={file}
theme={this.props.theme}
/>
));
return (
<View>
{fileAttachments}
</View>
);
}
}

View file

@ -0,0 +1,32 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {makeGetFilesForPost} from 'service/selectors/entities/files';
import {loadFilesForPostIfNecessary} from 'app/actions/views/channel';
import {getTheme} from 'service/selectors/entities/preferences';
import FileAttachmentList from './file_attachment_list';
function makeMapStateToProps() {
const getFilesForPost = makeGetFilesForPost();
return function mapStateToProps(state, ownProps) {
return {
...ownProps,
files: getFilesForPost(state, ownProps),
theme: getTheme(state)
};
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
loadFilesForPostIfNecessary
}, dispatch)
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(FileAttachmentList);

View file

@ -14,6 +14,7 @@ import FormattedText from 'app/components/formatted_text';
import FormattedTime from 'app/components/formatted_time';
import MattermostIcon from 'app/components/mattermost_icon';
import ProfilePicture from 'app/components/profile_picture';
import FileAttachmentList from 'app/components/file_attachment_list/file_attachment_list_container';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import {isSystemMessage} from 'service/utils/post_utils.js';
@ -166,6 +167,16 @@ export default class Post extends React.Component {
return <View style={replyBarStyle}/>;
}
renderFileAttachments() {
const {post} = this.props;
const fileIds = post.file_ids || [];
let attachments;
if (fileIds.length > 0) {
attachments = (<FileAttachmentList post={post}/>);
}
return attachments;
}
render() {
const style = getStyleSheet(this.props.theme);
const PROFILE_PICTURE_SIZE = 32;
@ -251,6 +262,7 @@ export default class Post extends React.Component {
</View>
<View style={style.messageContainerWithReplyBar}>
{this.renderReplyBar(style)}
{this.renderFileAttachments()}
<Text style={messageStyle}>
{this.props.post.message}
</Text>
@ -275,6 +287,7 @@ export default class Post extends React.Component {
</Text>
</View>
<View style={style.messageContainer}>
{this.renderFileAttachments()}
<Text style={messageStyle}>
{this.props.post.message}
</Text>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

View file

@ -37,6 +37,7 @@
"eslint-plugin-mocha": "4.8.0",
"eslint-plugin-react": "6.8.0",
"fetch-mock": "5.8.1",
"form-data": "2.1.2",
"mocha": "3.2.0",
"react-addons-test-utils": "15.4.2",
"react-dom": "15.4.2",

34
service/actions/files.js Normal file
View file

@ -0,0 +1,34 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {batchActions} from 'redux-batched-actions';
import Client from 'service/client';
import {FilesTypes} from 'service/constants';
import {forceLogoutIfNecessary} from './helpers';
export function getFilesForPost(teamId, channelId, postId) {
return async (dispatch, getState) => {
dispatch({type: FilesTypes.FETCH_FILES_FOR_POST_REQUEST}, getState);
let files;
try {
files = await Client.getFileInfosForPost(teamId, channelId, postId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: FilesTypes.FETCH_FILES_FOR_POST_FAILURE, error}, getState);
return;
}
dispatch(batchActions([
{
type: FilesTypes.RECEIVED_FILES_FOR_POST,
data: files,
postId
},
{
type: FilesTypes.FETCH_FILES_FOR_POST_SUCCESS
}
]), getState);
};
}

View file

@ -127,9 +127,13 @@ export default class Client {
headers[HEADER_AUTH] = `${HEADER_BEARER} ${this.token}`;
}
if (options.headers) {
Object.assign(headers, options.headers);
}
return {
headers,
...options
...options,
headers
};
}
@ -613,6 +617,26 @@ export default class Client {
);
};
getFileInfosForPost = async (teamId, channelId, postId) => {
return this.doFetch(
`${this.getChannelNeededRoute(teamId, channelId)}/posts/${postId}/get_file_infos`,
{method: 'get'}
);
};
uploadFile = async (teamId, channelId, clientId, fileFormData, formBoundary) => {
return this.doFetch(
`${this.getTeamNeededRoute(teamId)}/files/upload`,
{
method: 'post',
headers: {
'Content-Type': `multipart/form-data; boundary=${formBoundary}`
},
body: fileFormData
}
);
};
// Preferences routes
getMyPreferences = async () => {
return this.doFetch(

View file

@ -37,4 +37,19 @@ const Constants = {
POST_PURPOSE_CHANGE: 'system_purpose_change'
};
export default Constants;
const FileConstants = {
AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'],
CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'tex', 'txt', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'],
IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg'],
PATCH_TYPES: ['patch'],
PDF_TYPES: ['pdf'],
PRESENTATION_TYPES: ['ppt', 'pptx'],
SPREADSHEET_TYPES: ['xlsx', 'csv'],
VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'],
WORD_TYPES: ['doc', 'docx']
};
export default {
...Constants,
...FileConstants
};

View file

@ -0,0 +1,14 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import keyMirror from 'service/utils/key_mirror';
const FilesTypes = keyMirror({
FETCH_FILES_FOR_POST_REQUEST: null,
FETCH_FILES_FOR_POST_SUCCESS: null,
FETCH_FILES_FOR_POST_FAILURE: null,
RECEIVED_FILES_FOR_POST: null
});
export default FilesTypes;

View file

@ -7,6 +7,7 @@ import GeneralTypes from './general';
import UsersTypes from './users';
import TeamsTypes from './teams';
import PostsTypes from './posts';
import FilesTypes from './files';
import PreferencesTypes from './preferences';
import RequestStatus from './request_status';
import WebsocketEvents from './websocket';
@ -23,6 +24,7 @@ export {
TeamsTypes,
ChannelTypes,
PostsTypes,
FilesTypes,
PreferencesTypes,
Preferences,
RequestStatus,

View file

@ -0,0 +1,47 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {combineReducers} from 'redux';
import {FilesTypes, UsersTypes} from 'service/constants';
function files(state = {}, action) {
switch (action.type) {
case FilesTypes.RECEIVED_FILES_FOR_POST: {
const filesById = action.data.reduce((filesMap, file) => {
return {...filesMap,
[file.id]: file
};
}, {});
return {...state,
...filesById
};
}
case UsersTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function fileIdsByPostId(state = {}, action) {
switch (action.type) {
case FilesTypes.RECEIVED_FILES_FOR_POST: {
const {data, postId} = action;
const filesIdsForPost = data.map((file) => file.id);
return {...state,
[postId]: filesIdsForPost
};
}
case UsersTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
export default combineReducers({
files,
fileIdsByPostId
});

View file

@ -8,6 +8,7 @@ import general from './general';
import users from './users';
import teams from './teams';
import posts from './posts';
import files from './files';
import preferences from './preferences';
export default combineReducers({
@ -16,5 +17,6 @@ export default combineReducers({
teams,
channels,
posts,
files,
preferences
});

View file

@ -0,0 +1,21 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {combineReducers} from 'redux';
import {handleRequest, initialRequestState} from './helpers';
import {FilesTypes} from 'service/constants';
function getFilesForPost(state = initialRequestState(), action) {
return handleRequest(
FilesTypes.FETCH_FILES_FOR_POST_REQUEST,
FilesTypes.FETCH_FILES_FOR_POST_SUCCESS,
FilesTypes.FETCH_FILES_FOR_POST_FAILURE,
state,
action
);
}
export default combineReducers({
getFilesForPost
});

View file

@ -4,6 +4,7 @@
import {combineReducers} from 'redux';
import channels from './channels';
import files from './files';
import general from './general';
import posts from './posts';
import teams from './teams';
@ -12,6 +13,7 @@ import preferences from './preferences';
export default combineReducers({
channels,
files,
general,
posts,
teams,

View file

@ -0,0 +1,21 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {createSelector} from 'reselect';
function getAllFiles(state) {
return state.entities.files.files;
}
function getFilesIdsForPost(state, props) {
return state.entities.files.fileIdsByPostId[props.post.id] || [];
}
export function makeGetFilesForPost() {
return createSelector(
[getAllFiles, getFilesIdsForPost],
(allFiles, fileIdsForPost) => {
return fileIdsForPost.map((id) => allFiles[id]);
}
);
}

View file

@ -0,0 +1,42 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Constants} from 'service/constants';
export function getFormattedFileSize(file) {
const bytes = file.size;
const fileSizes = [
['TB', 1024 * 1024 * 1024 * 1024],
['GB', 1024 * 1024 * 1024],
['MB', 1024 * 1024],
['KB', 1024]
];
const size = fileSizes.find((unitAndMinBytes) => {
const minBytes = unitAndMinBytes[1];
return bytes > minBytes;
});
if (size) {
return `${Math.floor(bytes / size[1])} ${size[0]}`;
}
return `${bytes} B`;
}
export function getFileType(file) {
const fileExt = file.extension.toLowerCase();
const fileTypes = [
'image',
'code',
'pdf',
'video',
'audio',
'spreadsheet',
'word',
'presentation',
'patch'
];
return fileTypes.find((fileType) => {
const constForFileTypeExtList = `${fileType}_types`.toUpperCase();
const fileTypeExts = Constants[constForFileTypeExtList];
return fileTypeExts.indexOf(fileExt) > -1;
}) || 'other';
}

BIN
test/assets/images/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -0,0 +1,69 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import fs from 'fs';
import assert from 'assert';
const FormData = require('form-data');
import * as Actions from 'service/actions/files';
import Client from 'service/client';
import configureStore from 'app/store';
import {RequestStatus} from 'service/constants';
import TestHelper from 'test/test_helper';
describe('Actions.Files', () => {
let store;
before(async () => {
await TestHelper.initBasic(Client);
});
beforeEach(() => {
store = configureStore();
});
after(async () => {
await TestHelper.basicClient.logout();
});
it('getFilesForPost', async () => {
const {basicClient, basicTeam, basicChannel} = TestHelper;
const testFileName = 'test.png';
const testImageData = fs.createReadStream(`test/assets/images/${testFileName}`);
const clientId = TestHelper.generateId();
const imageFormData = new FormData();
imageFormData.append('files', testImageData);
imageFormData.append('channel_id', basicChannel.id);
imageFormData.append('client_ids', clientId);
const formBoundary = imageFormData.getBoundary();
const fileUploadResp = await basicClient.
uploadFile(basicTeam.id, basicChannel.id, clientId, imageFormData, formBoundary);
const fileId = fileUploadResp.file_infos[0].id;
const fakePostForFile = TestHelper.fakePost(basicChannel.id);
fakePostForFile.file_ids = [fileId];
const postForFile = await basicClient.createPost(basicTeam.id, fakePostForFile);
await Actions.getFilesForPost(
basicTeam.id, basicChannel.id, postForFile.id
)(store.dispatch, store.getState);
const filesRequest = store.getState().requests.files.getFilesForPost;
const {files: allFiles, fileIdsByPostId} = store.getState().entities.files;
if (filesRequest.status === RequestStatus.FAILURE) {
throw new Error(JSON.stringify(filesRequest.error));
}
assert.ok(allFiles);
assert.ok(allFiles[fileId]);
assert.equal(allFiles[fileId].id, fileId);
assert.equal(allFiles[fileId].name, testFileName);
assert.ok(fileIdsByPostId);
assert.ok(fileIdsByPostId[postForFile.id]);
assert.equal(fileIdsByPostId[postForFile.id][0], fileId);
});
});