Compare commits

..

5 Commits

Author SHA1 Message Date
Harrison Healey
f5bd4bd752 Fixed MATTERMOST-MOBILE-ANDROID-87 (#1566) 2018-04-02 23:02:42 +03:00
Harrison Healey
868e4b8ad6 Fixed MATTERMOST-MOBILE-ANDROID-85 (#1565) 2018-04-02 23:02:12 +03:00
Elias Nahum
097244692c Fix iOS Share Extension Crash and Bump build and version numbers (1.7) (#1556)
* Fix iOS Extension crash

* Bump app version to 1.7.1 and build to 92
2018-03-29 16:17:19 +03:00
Harrison Healey
6a4b729bc1 MM-9940 Updated CommonMark to not use String.prototype.startsWith (#1542) 2018-03-28 22:50:36 +08:00
Harrison Healey
6d6735f016 Locked redux to 1.7 release branch (#1549) 2018-03-28 10:11:17 +03:00
177 changed files with 10590 additions and 23020 deletions

View File

@@ -259,4 +259,4 @@
"yoda": [2, "never", {"exceptRange": false, "onlyEquality": false}],
"mocha/no-exclusive-tests": 2
}
}
}

View File

@@ -1,50 +1,5 @@
# Mattermost Mobile Apps Changelog
## v1.7.1 Release
- Release Date: April 3, 2018
- Server Versions Supported: Server v4.0+ is required, Self-Signed SSL Certificates are not supported
### Bug Fixes
- Fixed an issue where the iOS share extension sometimes crashed the Mattermost app
- Fixed an issue preventing Markdown tables from rendering with some international characters
## v1.7.0 Release
- Release Date: March 26, 2018
- Server Versions Supported: Server v4.0+ is required, Self-Signed SSL Certificates are not supported
### Highlights
#### iOS File Sharing
- Share files and images from other applications as attached files in Mattermost
#### Markdown Tables
- Tables created using markdown formatting can now be viewed in the app
#### Permalinks
- Permalinks now open in the app instead of launching a browser window
### Improvements
- Increased the tappable area of various icons for improved usability
- Announcement banners now display in the app
- Added "+" button to add emoji reactions to a post
- Minor performance improvements for app launch time
- Text files can now be viewed in the app
- Support for email autolinking into the app
### Bugs
- Fixed an issue causing some devices to hang at the splash screen on app launch
- Fixed an issue causing some letters to be hidden in the Android search input box
- Fixed an issue causing some Direct Message channels to show date stamps below the most recent message
- Fixed an issue where users weren't able to join open teams they've never been a member of
- Fixed an issue so double tapping buttons can no longer cause UI issues
- Fixed an issue where changing the channel display name wasn't being updated in the UI appropriately
- Fixed an issue where searhing for public channels sometimes showed no results
- Fixed an issue where the post menu could remain open while scrolling in the post list
- Fixed an issue where the system message to add users to a channel was missing the execution link
- Fixed an issue where bulleted lists cut off text if nested deeper than two levels
- Fixed an issue where logging into an account that is not on any team freezes the app
- Fixed an issue on iOS causing the app to crash when taking a photo then attaching it to a post
## v1.6.1 Release
- Release Date: February 13, 2018
- Server Versions Supported: Server v4.0+ is required, Self-Signed SSL Certificates are not supported

View File

@@ -10,14 +10,14 @@ OS := $(shell sh -c 'uname -s 2>/dev/null')
BASE_ASSETS = $(shell find assets/base -type d) $(shell find assets/base -type f -name '*')
OVERRIDE_ASSETS = $(shell find assets/override -type d 2> /dev/null) $(shell find assets/override -type f -name '*' 2> /dev/null)
.npminstall: package.json
@if ! [ $(shell which npm 2> /dev/null) ]; then \
echo "npm is not installed https://npmjs.com"; \
.yarninstall: package.json
@if ! [ $(shell which yarn 2> /dev/null) ]; then \
echo "yarn is not installed https://yarnpkg.com"; \
exit 1; \
fi
@echo Getting Javascript dependencies
@npm install
@yarn install --pure-lockfile
@touch $@
@@ -43,23 +43,22 @@ dist/assets: $(BASE_ASSETS) $(OVERRIDE_ASSETS)
@echo "Generating app assets"
@node scripts/make-dist-assets.js
pre-run: | .npminstall .podinstall dist/assets ## Installs dependencies and assets
pre-run: | .yarninstall .podinstall dist/assets ## Installs dependencies and assets
check-style: .npminstall ## Runs eslint
check-style: .yarninstall ## Runs eslint
@echo Checking for style guide compliance
@npm run check
@yarn run check
clean: ## Cleans dependencies, previous builds and temp files
@echo Cleaning started
@yarn cache clean
@rm -rf node_modules
@rm -f .npminstall
@rm -f .yarninstall
@rm -f .podinstall
@rm -rf dist
@rm -rf ios/build
@rm -rf ios/Pods
@rm -rf android/app/build
@echo Cleanup finished
post-install:
@@ -81,7 +80,7 @@ post-install:
sed $ -i'' -e "s|const ReactNative = require('ReactNative');|const ReactNative = require('ReactNative');`echo $\\\\\\r;`const Platform = require('Platform');|g" node_modules/react-native/Libraries/Lists/VirtualizedList.js; \
fi
@sed -i'' -e 's|transform: \[{scaleY: -1}\],|...Platform.select({android: {transform: \[{perspective: 1}, {scaleY: -1}\]}, ios: {transform: \[{scaleY: -1}\]}}),|g' node_modules/react-native/Libraries/Lists/VirtualizedList.js
@cd ./node_modules/mattermost-redux && npm run build
@cd ./node_modules/mattermost-redux && yarn run build
start: | pre-run ## Starts the React Native packager server
@if [ $(shell ps -e | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \
@@ -89,6 +88,7 @@ start: | pre-run ## Starts the React Native packager server
node ./node_modules/react-native/local-cli/cli.js start; \
else \
echo React Native packager server already running; \
ps -e | grep -i "cli.js start" | grep -iv grep | awk '{print $$1}' > server.PID; \
fi
stop: ## Stops the React Native packager server
@@ -139,19 +139,11 @@ run-ios: | check-device-ios pre-run ## Runs the app on an iOS simulator
@if [ $(shell ps -e | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \
echo Starting React Native packager server; \
node ./node_modules/react-native/local-cli/cli.js start & echo Running iOS app in development; \
if [ ! -z "${SIMULATOR}" ]; then \
react-native run-ios --simulator="${SIMULATOR}"; \
else \
react-native run-ios; \
fi; \
react-native run-ios --simulator="${SIMULATOR}"; \
wait; \
else \
echo Running iOS app in development; \
if [ ! -z "${SIMULATOR}" ]; then \
react-native run-ios --simulator="${SIMULATOR}"; \
else \
react-native run-ios; \
fi; \
react-native run-ios --simulator="${SIMULATOR}"; \
fi
run-android: | check-device-android pre-run prepare-android-build ## Runs the app on an Android emulator or dev device
@@ -226,7 +218,7 @@ unsigned-android: pre-run check-style prepare-android-build
@ps -e | grep -i "cli.js start" | grep -iv grep | awk '{print $$1}' | xargs kill -9
test: | pre-run check-style ## Runs tests
@npm test
@yarn test
## Help documentation https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
help:

View File

@@ -81,8 +81,8 @@ apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
if (System.getenv("SENTRY_ENABLED") == "true") {
project.ext.sentryCli = [
logLevel: "error",
flavorAware: false
logLevel: "debug",
flavorAware: true
]
apply from: "../../node_modules/react-native-sentry/sentry.gradle"
@@ -111,8 +111,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion 21
targetSdkVersion 23
versionCode 101
versionName "1.8.0"
versionCode 92
versionName "1.7.1"
multiDexEnabled true
ndk {
abiFilters "armeabi-v7a", "x86"

View File

@@ -57,8 +57,7 @@
android:name="com.reactnativenavigation.controllers.NavigationActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"/>
<activity
android:noHistory="true"
android:excludeFromRecents="true"
android:noHistory="false"
android:name="com.mattermost.share.ShareActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:label="@string/app_name"

View File

@@ -70,14 +70,10 @@ public class RealPathUtil {
return uri.getLastPathSegment();
}
try {
String path = getDataColumn(context, uri, null, null);
String path = getDataColumn(context, uri, null, null);
if (path != null) {
return path;
}
} catch (Exception e) {
// do nothing and try to get a temp file
if (path != null) {
return path;
}
// Try save to tmp file, and return tmp file path
@@ -93,12 +89,7 @@ public class RealPathUtil {
File tmpFile;
try {
String fileName = uri.getLastPathSegment();
File cacheDir = new File(context.getCacheDir(), "mmShare");
if (!cacheDir.exists()) {
cacheDir.mkdirs();
}
tmpFile = File.createTempFile("tmp", fileName, cacheDir);
tmpFile = File.createTempFile("tmp", fileName, context.getCacheDir());
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r");
@@ -181,22 +172,4 @@ public class RealPathUtil {
File file = new File(filePath);
return getMimeType(file);
}
public static void deleteTempFiles(final File dir) {
try {
if (dir.isDirectory()) {
deleteRecursive(dir);
}
} catch (Exception e) {
// do nothing
}
}
private static void deleteRecursive(File fileOrDirectory) {
if (fileOrDirectory.isDirectory())
for (File child : fileOrDirectory.listFiles())
deleteRecursive(child);
fileOrDirectory.delete();
}
}

View File

@@ -40,7 +40,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
public ShareModule(ReactApplicationContext reactContext) {
super(reactContext);
}
private File tempFolder;
@Override
public String getName() {
@@ -61,7 +60,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
@ReactMethod
public void close(ReadableMap data) {
this.clear();
getCurrentActivity().finish();
if (data != null) {
@@ -80,8 +78,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
}
}
}
RealPathUtil.deleteTempFiles(this.tempFolder);
}
@ReactMethod
@@ -100,7 +96,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
Activity currentActivity = getCurrentActivity();
if (currentActivity != null) {
this.tempFolder = new File(currentActivity.getCacheDir(), "mmShare");
Intent intent = currentActivity.getIntent();
action = intent.getAction();
type = intent.getType();

View File

@@ -3,6 +3,9 @@
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowIsTranslucent">false</item>
<item name="android:windowBackground">@color/white</item>
<item name="android:colorBackground">@color/white</item>
</style>
</resources>

View File

@@ -28,11 +28,9 @@ import {
isDirectChannel,
isGroupChannel,
} from 'mattermost-redux/utils/channel_utils';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {getLastCreateAt} from 'mattermost-redux/utils/post_utils';
import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from 'app/constants/post_textbox';
import {isDirectChannelVisible, isGroupChannelVisible} from 'app/utils/channels';
const MAX_POST_TRIES = 3;
@@ -322,14 +320,54 @@ export function handlePostDraftChanged(channelId, draft) {
};
}
export function handlePostDraftSelectionChanged(channelId, cursorPosition) {
return {
type: ViewTypes.POST_DRAFT_SELECTION_CHANGED,
channelId,
cursorPosition,
};
}
export function insertToDraft(value) {
return (dispatch, getState) => {
const state = getState();
const channelId = getCurrentChannelId(state);
const threadId = state.entities.posts.selectedPostId;
const insertEvent = threadId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
let draft;
let cursorPosition;
let action;
if (state.views.thread.drafts[threadId]) {
const threadDraft = state.views.thread.drafts[threadId];
draft = threadDraft.draft;
cursorPosition = threadDraft.cursorPosition;
action = {
type: ViewTypes.COMMENT_DRAFT_CHANGED,
rootId: threadId,
};
} else if (state.views.channel.drafts[channelId]) {
const channelDraft = state.views.channel.drafts[channelId];
draft = channelDraft.draft;
cursorPosition = channelDraft.cursorPosition;
action = {
type: ViewTypes.POST_DRAFT_CHANGED,
channelId,
};
}
EventEmitter.emit(insertEvent, value);
let nextDraft = `${value}`;
if (cursorPosition > 0) {
const beginning = draft.slice(0, cursorPosition);
const end = draft.slice(cursorPosition);
nextDraft = `${beginning}${value}${end}`;
}
if (action && nextDraft !== draft) {
dispatch({
...action,
draft: nextDraft,
});
}
};
}
@@ -400,23 +438,11 @@ export function refreshChannelWithRetry(channelId) {
export function leaveChannel(channel, reset = false) {
return async (dispatch, getState) => {
const state = getState();
const {currentChannelId} = state.entities.channels;
const {currentTeamId} = state.entities.teams;
dispatch({
type: ViewTypes.REMOVE_LAST_CHANNEL_FOR_TEAM,
data: {
teamId: currentTeamId,
channelId: channel.id,
},
});
if (channel.id === currentChannelId || reset) {
await dispatch(selectInitialChannel(currentTeamId));
}
const {currentTeamId} = getState().entities.teams;
await serviceLeaveChannel(channel.id)(dispatch, getState);
if (channel.isCurrent || reset) {
await selectInitialChannel(currentTeamId)(dispatch, getState);
}
};
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
export function addFileToFetchCache(url) {
return {
type: ViewTypes.ADD_FILE_TO_FETCH_CACHE,
url,
};
}

View File

@@ -1,15 +1,22 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {FileTypes} from 'mattermost-redux/action_types';
import {Platform} from 'react-native';
import {uploadFile} from 'mattermost-redux/actions/files';
import {
buildFileUploadData,
encodeHeaderURIStringToUTF8,
generateId,
} from 'app/utils/file';
import {ViewTypes} from 'app/constants';
import {buildFileUploadData, generateId} from 'app/utils/file';
export function initUploadFiles(files, rootId) {
return (dispatch, getState) => {
export function handleUploadFiles(files, rootId) {
return async (dispatch, getState) => {
const state = getState();
const channelId = state.entities.channels.currentChannelId;
const formData = new FormData();
const clientIds = [];
files.forEach((file) => {
@@ -20,36 +27,30 @@ export function initUploadFiles(files, rootId) {
clientId,
localPath: fileData.uri,
name: fileData.name,
type: fileData.type,
type: fileData.mimeType,
extension: fileData.extension,
});
fileData.name = encodeHeaderURIStringToUTF8(fileData.name);
formData.append('files', fileData);
formData.append('channel_id', channelId);
formData.append('client_ids', clientId);
});
let formBoundary;
if (Platform.os === 'ios') {
formBoundary = '--mobile.client.file.upload';
}
dispatch({
type: ViewTypes.SET_TEMP_UPLOAD_FILES_FOR_POST_DRAFT,
clientIds,
channelId,
rootId,
});
};
}
export function uploadFailed(clientIds, channelId, rootId, error) {
return {
type: FileTypes.UPLOAD_FILES_FAILURE,
clientIds,
channelId,
rootId,
error,
};
}
export function uploadComplete(data, channelId, rootId) {
return {
type: FileTypes.RECEIVED_UPLOAD_FILES,
data,
channelId,
rootId,
const clientIdsArray = clientIds.map((c) => c.clientId);
await uploadFile(channelId, rootId, clientIdsArray, formData, formBoundary)(dispatch, getState);
};
}
@@ -58,6 +59,20 @@ export function retryFileUpload(file, rootId) {
const state = getState();
const channelId = state.entities.channels.currentChannelId;
const formData = new FormData();
const fileData = buildFileUploadData(file);
fileData.uri = file.localPath;
fileData.name = encodeHeaderURIStringToUTF8(fileData.name);
formData.append('files', fileData);
formData.append('channel_id', channelId);
formData.append('client_ids', file.clientId);
let formBoundary;
if (Platform.os === 'ios') {
formBoundary = '--mobile.client.file.upload';
}
dispatch({
type: ViewTypes.RETRY_UPLOAD_FILE_FOR_POST,
@@ -65,6 +80,8 @@ export function retryFileUpload(file, rootId) {
channelId,
rootId,
});
await uploadFile(channelId, rootId, [file.clientId], formData, formBoundary)(dispatch, getState);
};
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Posts} from 'mattermost-redux/constants';
import {PostTypes} from 'mattermost-redux/action_types';
import {generateId} from 'app/utils/file';
export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
return async (dispatch) => {
const timestamp = Date.now();
const post = {
id: generateId(),
user_id: user.id,
channel_id: channelId,
message,
type: Posts.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL,
create_at: timestamp,
update_at: timestamp,
root_id: postRootId,
parent_id: postRootId,
props: {
username: user.username,
addedUsername,
},
};
dispatch({
type: PostTypes.RECEIVED_POSTS,
data: {
order: [],
posts: {
[post.id]: post,
},
},
channelId,
});
};
}

View File

@@ -8,7 +8,7 @@ import Svg, {
Path,
} from 'react-native-svg';
export default class AppIcon extends PureComponent {
export default class AwayStatus extends PureComponent {
static propTypes = {
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,

View File

@@ -6,8 +6,6 @@ import PropTypes from 'prop-types';
import {Clipboard, Platform, Text} from 'react-native';
import {intlShape} from 'react-intl';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import CustomPropTypes from 'app/constants/custom_prop_types';
import mattermostManaged from 'app/mattermost_managed';
@@ -20,29 +18,30 @@ export default class AtMention extends React.PureComponent {
onLongPress: PropTypes.func.isRequired,
onPostPress: PropTypes.func,
textStyle: CustomPropTypes.Style,
teammateNameDisplay: PropTypes.string,
theme: PropTypes.object.isRequired,
usersByUsername: PropTypes.object.isRequired,
};
static contextTypes = {
intl: intlShape,
};
}
constructor(props) {
super(props);
const user = this.getUserDetailsFromMentionName(props);
const userDetails = this.getUserDetailsFromMentionName(props);
this.state = {
user,
username: userDetails.username,
id: userDetails.id,
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.mentionName !== this.props.mentionName || nextProps.usersByUsername !== this.props.usersByUsername) {
const user = this.getUserDetailsFromMentionName(nextProps);
const userDetails = this.getUserDetailsFromMentionName(nextProps);
this.setState({
user,
username: userDetails.username,
id: userDetails.id,
});
}
}
@@ -56,7 +55,7 @@ export default class AtMention extends React.PureComponent {
animated: true,
backButtonTitle: '',
passProps: {
userId: this.state.user.id,
userId: this.state.id,
},
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
@@ -78,7 +77,11 @@ export default class AtMention extends React.PureComponent {
while (mentionName.length > 0) {
if (props.usersByUsername.hasOwnProperty(mentionName)) {
return props.usersByUsername[mentionName];
const user = props.usersByUsername[mentionName];
return {
username: user.username,
id: user.id,
};
}
// Repeatedly trim off trailing punctuation in case this is at the end of a sentence
@@ -111,22 +114,22 @@ export default class AtMention extends React.PureComponent {
}
this.props.onLongPress(action);
};
}
handleCopyMention = () => {
const {username} = this.state;
Clipboard.setString(`@${username}`);
};
}
render() {
const {isSearchResult, mentionName, mentionStyle, onPostPress, teammateNameDisplay, textStyle} = this.props;
const {user} = this.state;
const {isSearchResult, mentionName, mentionStyle, onPostPress, textStyle} = this.props;
const username = this.state.username;
if (!user.username) {
if (!username) {
return <Text style={textStyle}>{'@' + mentionName}</Text>;
}
const suffix = this.props.mentionName.substring(user.username.length);
const suffix = this.props.mentionName.substring(username.length);
return (
<Text
@@ -135,7 +138,7 @@ export default class AtMention extends React.PureComponent {
onLongPress={this.handleLongPress}
>
<Text style={mentionStyle}>
{'@' + displayUsername(user, teammateNameDisplay)}
{'@' + username}
</Text>
{suffix}
</Text>

View File

@@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import {getUsersByUsername} from 'mattermost-redux/selectors/entities/users';
import {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import AtMention from './at_mention';
@@ -13,7 +13,6 @@ function mapStateToProps(state) {
return {
theme: getTheme(state),
usersByUsername: getUsersByUsername(state),
teammateNameDisplay: getTeammateNameDisplaySetting(state),
};
}

View File

@@ -1,6 +1,6 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import {injectIntl, intlShape} from 'react-intl';
import {
Alert,
Platform,
@@ -14,11 +14,12 @@ import Permissions from 'react-native-permissions';
import {PermissionTypes} from 'app/constants';
import {changeOpacity} from 'app/utils/theme';
export default class AttachmentButton extends PureComponent {
class AttachmentButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
children: PropTypes.node,
fileCount: PropTypes.number,
intl: intlShape.isRequired,
maxFileCount: PropTypes.number.isRequired,
navigator: PropTypes.object.isRequired,
onShowFileMaxWarning: PropTypes.func,
@@ -31,12 +32,8 @@ export default class AttachmentButton extends PureComponent {
maxFileCount: 5,
};
static contextTypes = {
intl: intlShape.isRequired,
};
attachFileFromCamera = async () => {
const {formatMessage} = this.context.intl;
const {formatMessage} = this.props.intl;
const options = {
quality: 1.0,
noData: true,
@@ -75,7 +72,7 @@ export default class AttachmentButton extends PureComponent {
};
attachFileFromLibrary = () => {
const {formatMessage} = this.context.intl;
const {formatMessage} = this.props.intl;
const options = {
quality: 1.0,
noData: true,
@@ -110,7 +107,7 @@ export default class AttachmentButton extends PureComponent {
};
attachVideoFromLibraryAndroid = () => {
const {formatMessage} = this.context.intl;
const {formatMessage} = this.props.intl;
const options = {
quality: 1.0,
mediaType: 'video',
@@ -143,7 +140,7 @@ export default class AttachmentButton extends PureComponent {
hasPhotoPermission = async () => {
if (Platform.OS === 'ios') {
const {formatMessage} = this.context.intl;
const {formatMessage} = this.props.intl;
let permissionRequest;
const hasPermissionToStorage = await Permissions.check('photo');
@@ -315,3 +312,4 @@ const style = StyleSheet.create({
},
});
export default injectIntl(AttachmentButton);

View File

@@ -166,7 +166,7 @@ export default class AtMention extends PureComponent {
completedDraft += value.substring(cursorPosition);
}
onChangeText(completedDraft);
onChangeText(completedDraft, true);
this.setState({mentionComplete: true});
};

View File

@@ -19,7 +19,6 @@ import SlashSuggestion from './slash_suggestion';
export default class Autocomplete extends PureComponent {
static propTypes = {
cursorPosition: PropTypes.number.isRequired,
deviceHeight: PropTypes.number,
onChangeText: PropTypes.func.isRequired,
rootId: PropTypes.string,
@@ -30,10 +29,10 @@ export default class Autocomplete extends PureComponent {
static defaultProps = {
isSearch: false,
cursorPosition: 0,
};
state = {
cursorPosition: 0,
atMentionCount: 0,
channelMentionCount: 0,
emojiCount: 0,
@@ -41,6 +40,12 @@ export default class Autocomplete extends PureComponent {
keyboardOffset: 0,
};
handleSelectionChange = (event) => {
this.setState({
cursorPosition: event.nativeEvent.selection.end,
});
};
handleAtMentionCountChange = (atMentionCount) => {
this.setState({atMentionCount});
};
@@ -111,15 +116,18 @@ export default class Autocomplete extends PureComponent {
<View style={containerStyle}>
<AtMention
listHeight={listHeight}
cursorPosition={this.state.cursorPosition}
onResultCountChange={this.handleAtMentionCountChange}
{...this.props}
/>
<ChannelMention
listHeight={listHeight}
cursorPosition={this.state.cursorPosition}
onResultCountChange={this.handleChannelMentionCountChange}
{...this.props}
/>
<EmojiSuggestion
cursorPosition={this.state.cursorPosition}
onResultCountChange={this.handleEmojiCountChange}
{...this.props}
/>

View File

@@ -82,9 +82,9 @@ export default class EmojiSuggestion extends Component {
return;
}
if (this.matchTerm.length) {
if (this.props.emojis !== nextProps.emojis) {
this.handleFuzzySearch(this.matchTerm, nextProps);
} else {
} else if (!this.matchTerm.length) {
const initialEmojis = [...nextProps.emojis];
initialEmojis.splice(0, 300);
const data = initialEmojis.sort();

View File

@@ -25,13 +25,11 @@ export default class ChannelItem extends PureComponent {
currentChannelId: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
fake: PropTypes.bool,
isChannelMuted: PropTypes.bool,
isMyUser: PropTypes.bool,
isUnread: PropTypes.bool,
mentions: PropTypes.number.isRequired,
navigator: PropTypes.object,
onSelectChannel: PropTypes.func.isRequired,
shouldHideChannel: PropTypes.bool,
status: PropTypes.string,
teammateDeletedAt: PropTypes.number,
type: PropTypes.string.isRequired,
@@ -78,21 +76,15 @@ export default class ChannelItem extends PureComponent {
channelId,
currentChannelId,
displayName,
isChannelMuted,
isMyUser,
isUnread,
mentions,
shouldHideChannel,
status,
teammateDeletedAt,
theme,
type,
} = this.props;
if (shouldHideChannel) {
return null;
}
const {intl} = this.context;
let channelDisplayName = displayName;
@@ -109,7 +101,6 @@ export default class ChannelItem extends PureComponent {
let extraItemStyle;
let extraTextStyle;
let extraBorder;
let mutedStyle;
if (isActive) {
extraItemStyle = style.itemActive;
@@ -136,10 +127,6 @@ export default class ChannelItem extends PureComponent {
);
}
if (isChannelMuted) {
mutedStyle = style.muted;
}
const icon = (
<ChannelIcon
isActive={isActive}
@@ -161,7 +148,7 @@ export default class ChannelItem extends PureComponent {
onPress={this.onPress}
onLongPress={this.onPreview}
>
<View style={[style.container, mutedStyle]}>
<View style={style.container}>
{extraBorder}
<View style={[style.item, extraItemStyle]}>
{icon}
@@ -194,6 +181,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
item: {
alignItems: 'center',
height: 44,
flex: 1,
flexDirection: 'row',
paddingLeft: 16,
@@ -204,13 +192,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
text: {
color: changeOpacity(theme.sidebarText, 0.4),
flex: 1,
fontSize: 14,
fontWeight: '600',
lineHeight: 16,
paddingRight: 40,
height: '100%',
flex: 1,
textAlignVertical: 'center',
lineHeight: 44,
},
textActive: {
color: theme.sidebarTextActiveColor,
@@ -231,8 +217,5 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
color: theme.mentionColor,
fontSize: 10,
},
muted: {
opacity: 0.5,
},
};
});

View File

@@ -4,10 +4,9 @@
import {connect} from 'react-redux';
import {General} from 'mattermost-redux/constants';
import {getCurrentChannelId, makeGetChannel, getMyChannelMember, isChannelReadOnlyById} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentChannelId, makeGetChannel, getMyChannelMember} from 'mattermost-redux/selectors/entities/channels';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import {isChannelMuted} from 'mattermost-redux/utils/channel_utils';
import ChannelItem from './channel_item';
@@ -16,9 +15,12 @@ function makeMapStateToProps() {
return (state, ownProps) => {
const channel = ownProps.channel || getChannel(state, {id: ownProps.channelId});
const member = getMyChannelMember(state, ownProps.channelId);
const currentUserId = getCurrentUserId(state);
let member;
if (ownProps.isUnread) {
member = getMyChannelMember(state, ownProps.channelId);
}
const currentUserId = getCurrentUserId(state);
let isMyUser = false;
let teammateDeletedAt = 0;
if (channel.type === General.DM_CHANNEL && channel.teammate_id) {
@@ -29,17 +31,12 @@ function makeMapStateToProps() {
}
}
const isReadOnly = isChannelReadOnlyById(state, channel.id);
const shouldHideChannel = !ownProps.isSearchResult && !ownProps.isFavorite && isReadOnly;
return {
currentChannelId: getCurrentChannelId(state),
displayName: channel.display_name,
fake: channel.fake,
isChannelMuted: isChannelMuted(member),
isMyUser,
mentions: member ? member.mention_count : 0,
shouldHideChannel,
status: channel.status,
teammateDeletedAt,
theme: getTheme(state),

View File

@@ -113,7 +113,6 @@ class FilteredList extends Component {
ref={channel.id}
channelId={channel.id}
channel={channel}
isSearchResult={true}
isUnread={false}
mentions={0}
onSelectChannel={this.onSelectChannel}

View File

@@ -12,10 +12,9 @@ import {
getSortedDirectChannelIds,
} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getTheme, getFavoritesPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {showCreateOption} from 'mattermost-redux/utils/channel_utils';
import {isAdmin as checkIsAdmin, isSystemAdmin as checkIsSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {isAdmin, isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import List from './list';
@@ -24,16 +23,12 @@ function mapStateToProps(state) {
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
const unreadChannelIds = getSortedUnreadChannelIds(state);
const favoriteChannelIds = getSortedFavoriteChannelIds(state);
const publicChannelIds = getSortedPublicChannelIds(state);
const privateChannelIds = getSortedPrivateChannelIds(state);
const directChannelIds = getSortedDirectChannelIds(state);
const currentTeamId = getCurrentTeamId(state);
const publicChannelIds = getSortedPublicChannelIds(state);
const isAdmin = checkIsAdmin(roles);
const isSystemAdmin = checkIsSystemAdmin(roles);
return {
canCreatePrivateChannels: showCreateOption(state, config, license, currentTeamId, General.PRIVATE_CHANNEL, isAdmin, isSystemAdmin),
canCreatePrivateChannels: showCreateOption(config, license, General.PRIVATE_CHANNEL, isAdmin(roles), isSystemAdmin(roles)),
unreadChannelIds,
favoriteChannelIds,
publicChannelIds,

View File

@@ -257,7 +257,6 @@ export default class List extends PureComponent {
return (
<ChannelItem
channelId={item}
isFavorite={this.props.favoriteChannelIds.includes(item)}
navigator={this.props.navigator}
onSelectChannel={this.onSelectChannel}
/>

View File

@@ -77,7 +77,6 @@ export default class TeamsListItem extends React.PureComponent {
teamId={teamId}
styleContainer={styles.teamIconContainer}
styleText={styles.teamIconText}
styleImage={styles.imageContainer}
/>
<View style={styles.teamNameContainer}>
<Text
@@ -135,9 +134,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
color: changeOpacity(theme.sidebarText, 0.5),
fontSize: 12,
},
imageContainer: {
backgroundColor: theme.sidebarBg,
},
checkmarkContainer: {
alignItems: 'flex-end',
},

View File

@@ -6,10 +6,10 @@ import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import ChannelLoader from './channel_loader';
function mapStateToProps(state, ownProps) {
function mapStateToProps(state) {
const {deviceWidth} = state.device.dimension;
return {
channelIsLoading: ownProps.channelIsLoading || state.views.channel.loading,
channelIsLoading: state.views.channel.loading,
deviceWidth,
theme: getTheme(state),
};

View File

@@ -1,240 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {FlatList, Platform, View} from 'react-native';
import Loading from 'app/components/loading';
import FormattedText from 'app/components/formatted_text';
import {emptyFunction} from 'app/utils/general';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
export default class CustomFlatList extends PureComponent {
static propTypes = {
/*
* The current theme.
*/
theme: PropTypes.object.isRequired,
/*
* An array of items to be rendered.
*/
items: PropTypes.array.isRequired,
/*
* A function or React element used to render the items in the list.
*/
renderItem: PropTypes.func,
/*
* Whether or not to render "No results" when the list contains no items.
*/
showNoResults: PropTypes.bool,
/*
* A function to get a unique key for each item in the list. If not provided, the id field of the item will be used as the key.
*/
keyExtractor: PropTypes.func,
/*
* Any extra data needed to render the list. If this value changes, all items of a list will be re-rendered.
*/
extraData: PropTypes.object,
/*
* A function called when an item in the list is pressed. Receives the item that was pressed as an argument.
*/
onRowPress: PropTypes.func,
/*
* A function called when the end of the list is reached. This can be triggered before this list end is reached
* by changing onListEndReachedThreshold.
*/
onListEndReached: PropTypes.func,
/*
* How soon before the end of the list onListEndReached should be called.
*/
onListEndReachedThreshold: PropTypes.number,
/*
* Whether or not to display the loading text.
*/
loading: PropTypes.bool,
/*
* The text displayed when loading is set to true.
*/
loadingText: PropTypes.object,
/*
* How many items to render when the list is first rendered.
*/
initialNumToRender: PropTypes.number,
};
static defaultProps = {
showNoResults: true,
keyExtractor: (item) => {
return item.id;
},
onListEndReached: emptyFunction,
onListEndReachedThreshold: 50,
loadingText: null,
initialNumToRender: 10,
};
constructor(props) {
super(props);
this.state = {
items: props.items,
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.items !== this.props.items) {
this.setState({
items: nextProps.items,
});
}
}
renderItem = ({item}) => {
const props = {
id: item.id,
item,
onPress: this.props.onRowPress,
};
// Allow passing in a component like UserListRow or ChannelListRow
if (this.props.renderItem.prototype.isReactElement) {
const RowComponent = this.props.renderItem;
return <RowComponent {...props}/>;
}
return this.props.renderItem(props);
};
renderItemSeparator = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return <View style={style.separator}/>;
};
renderFooter = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (!this.props.loading || !this.props.loadingText) {
return null;
}
if (this.props.items.length === 0) {
return null;
}
return (
<View style={style.loading}>
<FormattedText
{...this.props.loadingText}
style={style.loadingText}
/>
</View>
);
};
renderEmptyList = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (this.props.loading) {
return (
<View style={style.searching}>
<Loading/>
</View>
);
}
if (this.props.showNoResults) {
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}
return null;
};
render() {
const style = getStyleFromTheme(this.props.theme);
return (
<FlatList
style={style.listView}
initialNumToRender={this.props.initialNumToRender}
ItemSeparatorComponent={this.renderItemSeparator}
ListFooterComponent={this.renderFooter}
ListEmptyComponent={this.renderEmptyList}
onEndReached={this.props.onListEndReached}
onEndReachedThreshold={this.props.onListEndReachedThreshold}
extraData={this.props.extraData}
data={this.state.items}
keyExtractor={this.props.keyExtractor}
renderItem={this.renderItem}
keyboardShouldPersistTaps='handled'
/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg,
...Platform.select({
android: {
marginBottom: 20,
},
}),
},
loading: {
height: 70,
backgroundColor: theme.centerChannelBg,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
searching: {
backgroundColor: theme.centerChannelBg,
height: '100%',
position: 'absolute',
width: '100%',
},
separator: {
height: 1,
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
},
noResultContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});

View File

@@ -1,81 +0,0 @@
// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import FormattedText from 'app/components/formatted_text';
import AppIcon from 'app/components/app_icon';
import {ViewTypes} from 'app/constants';
class DeletedPost extends PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
};
render() {
const {theme} = this.props;
const style = getStyleSheet(theme);
return (
<View style={style.main}>
<View style={style.iconContainer}>
<AppIcon
color={theme.centerChannelColor}
height={ViewTypes.PROFILE_PICTURE_SIZE}
width={ViewTypes.PROFILE_PICTURE_SIZE}
/>
</View>
<View style={style.textContainer}>
<FormattedText
id='post_info.system'
defaultMessage='System'
style={style.displayName}
/>
<View style={style.messageContainer}>
<FormattedText
id='rhs_thread.rootPostDeletedMessage.body'
defaultMessage='Part of this thread has been deleted due to a data retention policy. You can no longer reply to this thread.'
style={style.message}
/>
</View>
</View>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const stdPadding = 12;
return {
main: {
flexDirection: 'row',
paddingTop: stdPadding,
},
iconContainer: {
paddingRight: stdPadding,
paddingLeft: stdPadding,
width: (stdPadding * 2) + ViewTypes.PROFILE_PICTURE_SIZE,
},
textContainer: {
paddingBottom: 10,
flex: 1,
marginRight: stdPadding,
},
messageContainer: {
marginTop: 3,
},
displayName: {
color: theme.centerChannelColor,
fontSize: 15,
fontWeight: '600',
},
message: {
color: changeOpacity(theme.centerChannelColor, 0.8),
fontSize: 15,
lineHeight: 22,
},
};
});
export default DeletedPost;

View File

@@ -34,7 +34,7 @@ export default class EditChannelInfo extends PureComponent {
enableRightButton: PropTypes.func,
saving: PropTypes.bool.isRequired,
editing: PropTypes.bool,
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
error: PropTypes.string,
displayName: PropTypes.string,
currentTeamUrl: PropTypes.string,
channelURL: PropTypes.string,

View File

@@ -473,7 +473,6 @@ export default class EmojiPicker extends PureComponent {
titleCancelColor={theme.centerChannelColor}
onChangeText={this.changeSearchTerm}
onCancelButtonPress={this.cancelSearch}
autoCapitalize='none'
value={searchTerm}
/>
</View>

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import Svg, {Path} from 'react-native-svg';
export default class CloudSvg extends PureComponent {
static propTypes = {
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
};
render() {
const {color, height, width} = this.props;
return (
<View style={{height, width, alignItems: 'flex-start'}}>
<Svg
width={width}
height={height}
viewBox='0 0 72 47'
>
<Path
d='M58.464,19.072c0,-5.181 -1.773,-9.599 -5.316,-13.249c-3.545,-3.649 -7.854,-5.474 -12.932,-5.474c-3.597,0 -6.902,0.979 -9.917,2.935c-3.014,1.959 -5.263,4.523 -6.743,7.696c-1.483,-0.739 -2.856,-1.111 -4.126,-1.111c-2.328,0 -4.363,0.769 -6.109,2.301c-1.745,1.535 -2.831,3.466 -3.252,5.792c-2.856,0.952 -5.185,2.672 -6.982,5.156c-1.8,2.487 -2.697,5.316 -2.697,8.489c0,3.915 1.4,7.299 4.204,10.155c2.802,2.857 6.161,4.285 10.076,4.285l43.794,0c3.595,0 6.664,-1.295 9.203,-3.888c2.538,-2.591 3.808,-5.685 3.808,-9.282c0,-3.702 -1.27,-6.848 -3.808,-9.441c-2.539,-2.591 -5.608,-3.888 -9.203,-3.888l0,-0.476Zm-31.294,16.424l17.17,0c-0.842,-1.62 -2.02,-2.92 -3.535,-3.898c-1.515,-0.977 -3.198,-1.467 -5.05,-1.467c-1.852,0 -3.535,0.49 -5.05,1.467c-1.515,0.978 -2.693,2.278 -3.535,3.898l0,0Zm17.338,-12.407c0,-0.782 -0.252,-1.411 -0.757,-1.886c-0.505,-0.474 -1.124,-0.713 -1.852,-0.713c-0.73,0 -1.347,0.239 -1.852,0.713c-0.505,0.475 -0.757,1.104 -0.757,1.886c0,0.783 0.252,1.412 0.757,1.886c0.505,0.476 1.122,0.713 1.852,0.713c0.728,0 1.347,-0.237 1.852,-0.713c0.505,-0.474 0.757,-1.103 0.757,-1.886Zm-12.288,0c0,-0.782 -0.253,-1.411 -0.758,-1.886c-0.505,-0.474 -1.123,-0.713 -1.851,-0.713c-0.73,0 -1.347,0.239 -1.852,0.713c-0.505,0.475 -0.757,1.104 -0.757,1.886c0,0.783 0.252,1.412 0.757,1.886c0.505,0.476 1.122,0.713 1.852,0.713c0.728,0 1.346,-0.237 1.851,-0.713c0.505,-0.474 0.758,-1.103 0.758,-1.886Z'
fillRule='evenodd'
strokeLinejoin='round'
fill={color}
/>
</Svg>
</View>
);
}
}

View File

@@ -1,85 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {TouchableOpacity, View} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import Cloud from './cloud';
export default class FailedNetworkAction extends PureComponent {
static propTypes = {
onRetry: PropTypes.func,
theme: PropTypes.object.isRequired,
};
render() {
const {theme, onRetry} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={style.container}>
<Cloud
color={changeOpacity(theme.centerChannelColor, 0.15)}
height={76}
width={76}
/>
<FormattedText
id='mobile.failed_network_action.title'
defaultMessage='No internet connection'
style={style.title}
/>
<FormattedText
id='mobile.failed_network_action.description'
defaultMessage='There seems to be a problem with your internet connection. Make sure you have an active connection and try again.'
style={style.description}
/>
{onRetry &&
<TouchableOpacity
onPress={onRetry}
style={style.retryContainer}
>
<FormattedText
id='mobile.failed_network_action.retry'
defaultMessage='Try Again'
style={style.retry}
/>
</TouchableOpacity>
}
</View>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
paddingHorizontal: 15,
},
title: {
color: changeOpacity(theme.centerChannelColor, 0.8),
fontSize: 20,
fontWeight: '600',
marginBottom: 15,
},
description: {
color: changeOpacity(theme.centerChannelColor, 0.4),
fontSize: 17,
textAlign: 'center',
},
retryContainer: {
marginTop: 30,
},
retry: {
color: changeOpacity(theme.centerChannelColor, 0.7),
fontSize: 16,
fontWeight: '600',
},
};
});

View File

@@ -9,22 +9,18 @@ import {
View,
} from 'react-native';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import * as Utils from 'mattermost-redux/utils/file_utils.js';
import {isDocument, isGif} from 'app/utils/file';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import FileAttachmentDocument from './file_attachment_document';
import FileAttachmentDocument, {SUPPORTED_DOCS_FORMAT} from './file_attachment_document';
import FileAttachmentIcon from './file_attachment_icon';
import FileAttachmentImage from './file_attachment_image';
export default class FileAttachment extends PureComponent {
static propTypes = {
deviceWidth: PropTypes.number.isRequired,
addFileToFetchCache: PropTypes.func.isRequired,
fetchCache: PropTypes.object.isRequired,
file: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
onCaptureRef: PropTypes.func,
onCapturePreviewRef: PropTypes.func,
onInfoPress: PropTypes.func,
onPreviewPress: PropTypes.func,
theme: PropTypes.object.isRequired,
@@ -36,51 +32,29 @@ export default class FileAttachment extends PureComponent {
onPreviewPress: () => true,
};
handleCaptureRef = (ref) => {
const {onCaptureRef, index} = this.props;
if (onCaptureRef) {
onCaptureRef(ref, index);
}
};
handleCapturePreviewRef = (ref) => {
const {onCapturePreviewRef, index} = this.props;
if (onCapturePreviewRef) {
onCapturePreviewRef(ref, index);
}
};
handlePreviewPress = () => {
this.props.onPreviewPress(this.props.index);
this.props.onPreviewPress(this.props.file);
};
renderFileInfo() {
const {file, theme} = this.props;
const {data} = file;
const style = getStyleSheet(theme);
if (!data || !data.id) {
if (!file.id) {
return null;
}
return (
<View style={style.attachmentContainer}>
<View>
<Text
numberOfLines={2}
ellipsizeMode='tail'
numberOfLines={4}
style={style.fileName}
>
{file.caption.trim()}
{file.name.trim()}
</Text>
<View style={style.fileDownloadContainer}>
<Text
numberOfLines={2}
ellipsizeMode='tail'
style={style.fileInfo}
>
{`${data.extension.toUpperCase()} ${Utils.getFormattedFileSize(data)}`}
<Text style={style.fileInfo}>
{`${file.extension.toUpperCase()} ${Utils.getFormattedFileSize(file)}`}
</Text>
</View>
</View>
@@ -88,53 +62,47 @@ export default class FileAttachment extends PureComponent {
}
render() {
const {
deviceWidth,
file,
onInfoPress,
theme,
navigator,
} = this.props;
const {data} = file;
const {file, onInfoPress, theme, navigator} = this.props;
const style = getStyleSheet(theme);
let mime = file.mime_type;
if (mime && mime.includes(';')) {
mime = mime.split(';')[0];
}
let fileAttachmentComponent;
if ((data && data.has_preview_image) || file.loading || isGif(data)) {
if (file.has_preview_image || file.loading || file.mime_type === 'image/gif') {
fileAttachmentComponent = (
<TouchableOpacity onPress={this.handlePreviewPress}>
<FileAttachmentImage
file={data || {}}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
addFileToFetchCache={this.props.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
theme={theme}
/>
</TouchableOpacity>
);
} else if (isDocument(data)) {
} else if (SUPPORTED_DOCS_FORMAT.includes(mime)) {
fileAttachmentComponent = (
<FileAttachmentDocument
file={file}
navigator={navigator}
theme={theme}
navigator={navigator}
/>
);
} else {
fileAttachmentComponent = (
<TouchableOpacity onPress={this.handlePreviewPress}>
<FileAttachmentIcon
file={data}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
file={file}
theme={theme}
/>
</TouchableOpacity>
);
}
const width = deviceWidth * 0.72;
return (
<View style={[style.fileWrapper, {width}]}>
<View style={style.fileWrapper}>
{fileAttachmentComponent}
<TouchableOpacity
onPress={onInfoPress}
@@ -149,10 +117,6 @@ export default class FileAttachment extends PureComponent {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
attachmentContainer: {
flex: 1,
justifyContent: 'center',
},
downloadIcon: {
color: changeOpacity(theme.centerChannelColor, 0.7),
marginRight: 5,
@@ -184,11 +148,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
flex: 1,
flexDirection: 'row',
marginTop: 10,
marginRight: 10,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 2,
maxWidth: 350,
},
circularProgress: {
width: '100%',

View File

@@ -5,10 +5,7 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Alert,
NativeModules,
NativeEventEmitter,
Platform,
StatusBar,
StyleSheet,
TouchableOpacity,
View,
@@ -17,20 +14,26 @@ import OpenFile from 'react-native-doc-viewer';
import RNFetchBlob from 'react-native-fetch-blob';
import {CircularProgress} from 'react-native-circular-progress';
import {intlShape} from 'react-intl';
import tinyColor from 'tinycolor2';
import {changeOpacity} from 'app/utils/theme';
import {getFileUrl} from 'mattermost-redux/utils/file_utils.js';
import {DeviceTypes} from 'app/constants/';
import {changeOpacity} from 'app/utils/theme';
import FileAttachmentIcon from './file_attachment_icon';
const {DOCUMENTS_PATH} = DeviceTypes;
const TEXT_PREVIEW_FORMATS = [
'application/json',
'application/x-x509-ca-cert',
export const SUPPORTED_DOCS_FORMAT = [
'application/pdf',
'application/msword',
'application/rtf',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/xml',
'text/csv',
'text/plain',
];
@@ -46,10 +49,10 @@ export default class FileAttachmentDocument extends PureComponent {
};
static defaultProps = {
iconHeight: 50,
iconWidth: 50,
wrapperHeight: 80,
wrapperWidth: 80,
iconHeight: 65,
iconWidth: 65,
wrapperHeight: 100,
wrapperWidth: 100,
};
static contextTypes = {
@@ -64,13 +67,10 @@ export default class FileAttachmentDocument extends PureComponent {
componentDidMount() {
this.mounted = true;
this.eventEmitter = new NativeEventEmitter(NativeModules.RNReactNativeDocViewer);
this.eventEmitter.addListener('DoneButtonEvent', () => this.setStatusBarColor());
}
componentWillUnmount() {
this.mounted = false;
this.eventEmitter.removeListener();
}
cancelDownload = () => {
@@ -83,25 +83,8 @@ export default class FileAttachmentDocument extends PureComponent {
}
};
setStatusBarColor = (style) => {
if (Platform.OS === 'ios') {
if (style) {
StatusBar.setBarStyle(style, true);
} else {
const {theme} = this.props;
const headerColor = tinyColor(theme.sidebarHeaderBg);
let barStyle = 'light-content';
if (headerColor.isLight() && Platform.OS === 'ios') {
barStyle = 'dark-content';
}
StatusBar.setBarStyle(barStyle, true);
}
}
};
downloadAndPreviewFile = async (file) => {
const {data} = file;
const path = `${DOCUMENTS_PATH}/${data.id}-${file.caption}`;
const path = `${DOCUMENTS_PATH}/${file.name}`;
this.setState({didCancel: false});
@@ -117,16 +100,16 @@ export default class FileAttachmentDocument extends PureComponent {
}
const options = {
session: data.id,
session: file.id,
timeout: 10000,
indicator: true,
overwrite: true,
path,
};
const mime = data.mime_type.split(';')[0];
const mime = file.mime_type.split(';')[0];
let openDocument = this.openDocument;
if (TEXT_PREVIEW_FORMATS.includes(mime)) {
if (mime === 'text/plain') {
openDocument = this.previewTextFile;
}
@@ -135,7 +118,7 @@ export default class FileAttachmentDocument extends PureComponent {
openDocument(file, 0);
} else {
this.setState({downloading: true});
this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(data.id));
this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(file.id));
this.downloadTask.progress((received, total) => {
const progress = (received / total) * 100;
if (this.mounted) {
@@ -180,16 +163,15 @@ export default class FileAttachmentDocument extends PureComponent {
previewTextFile = (file, delay = 2000) => {
const {navigator, theme} = this.props;
const {data} = file;
const prefix = Platform.OS === 'android' ? 'file:/' : '';
const path = `${DOCUMENTS_PATH}/${data.id}-${file.caption}`;
const path = `${DOCUMENTS_PATH}/${file.name}`;
const readFile = RNFetchBlob.fs.readFile(`${prefix}${path}`, 'utf8');
setTimeout(async () => {
try {
const content = await readFile;
navigator.push({
screen: 'TextPreview',
title: file.caption,
title: file.name,
animated: true,
backButtonTitle: '',
passProps: {
@@ -215,15 +197,12 @@ export default class FileAttachmentDocument extends PureComponent {
// shown nicely and smooth
setTimeout(() => {
if (!this.state.didCancel && this.mounted) {
const {data} = file;
const prefix = Platform.OS === 'android' ? 'file:/' : '';
const path = `${DOCUMENTS_PATH}/${data.id}-${file.caption}`;
this.setStatusBarColor('dark-content');
const path = `${DOCUMENTS_PATH}/${file.name}`;
OpenFile.openDoc([{
url: `${prefix}${path}`,
fileNameOptional: file.caption,
fileName: data.name,
fileType: data.extension,
fileName: file.name,
fileType: file.extension,
cache: false,
}], (error) => {
if (error) {
@@ -237,7 +216,7 @@ export default class FileAttachmentDocument extends PureComponent {
id: 'mobile.document_preview.failed_description',
defaultMessage: 'An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n',
}, {
fileType: data.extension.toUpperCase(),
fileType: file.extension.toUpperCase(),
}),
[{
text: intl.formatMessage({
@@ -246,10 +225,8 @@ export default class FileAttachmentDocument extends PureComponent {
}),
}]
);
this.setStatusBarColor();
RNFetchBlob.fs.unlink(path);
}
this.setState({downloading: false, progress: 0});
});
}
@@ -274,7 +251,7 @@ export default class FileAttachmentDocument extends PureComponent {
return (
<View style={[style.circularProgressContent, {width: wrapperWidth}]}>
<FileAttachmentIcon
file={file.data}
file={file}
iconHeight={iconHeight}
iconWidth={iconWidth}
theme={theme}
@@ -328,7 +305,7 @@ export default class FileAttachmentDocument extends PureComponent {
} else {
fileAttachmentComponent = (
<FileAttachmentIcon
file={file.data}
file={file}
theme={theme}
iconHeight={iconHeight}
iconWidth={iconWidth}

View File

@@ -5,13 +5,12 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
View,
Image,
StyleSheet,
} from 'react-native';
import * as Utils from 'mattermost-redux/utils/file_utils';
import ProgressiveImage from 'app/components/progressive_image';
import audioIcon from 'assets/images/icons/audio.png';
import codeIcon from 'assets/images/icons/code.png';
import excelIcon from 'assets/images/icons/excel.png';
@@ -41,8 +40,6 @@ export default class FileAttachmentIcon extends PureComponent {
file: PropTypes.object.isRequired,
iconHeight: PropTypes.number,
iconWidth: PropTypes.number,
onCaptureRef: PropTypes.func,
onCapturePreviewRef: PropTypes.func,
wrapperHeight: PropTypes.number,
wrapperWidth: PropTypes.number,
};
@@ -50,8 +47,8 @@ export default class FileAttachmentIcon extends PureComponent {
static defaultProps = {
iconHeight: 60,
iconWidth: 60,
wrapperHeight: 80,
wrapperWidth: 80,
wrapperHeight: 100,
wrapperWidth: 100,
};
getFileIconPath(file) {
@@ -59,35 +56,16 @@ export default class FileAttachmentIcon extends PureComponent {
return ICON_PATH_FROM_FILE_TYPE[fileType] || ICON_PATH_FROM_FILE_TYPE.other;
}
handleCaptureRef = (ref) => {
const {onCaptureRef} = this.props;
if (onCaptureRef) {
onCaptureRef(ref);
}
};
handleCapturePreviewRef = (ref) => {
const {onCapturePreviewRef} = this.props;
if (onCapturePreviewRef) {
onCapturePreviewRef(ref);
}
};
render() {
const {file, iconHeight, iconWidth, wrapperHeight, wrapperWidth} = this.props;
const source = this.getFileIconPath(file);
return (
<View
ref={this.handleCaptureRef}
style={[styles.fileIconWrapper, {height: wrapperHeight, width: wrapperWidth}]}
>
<ProgressiveImage
ref={this.handleCapturePreviewRef}
style={[styles.icon, {height: iconHeight, width: iconWidth}]}
defaultSource={source}
<View style={[styles.fileIconWrapper, {height: wrapperHeight, width: wrapperWidth}]}>
<Image
style={{height: iconHeight, width: iconWidth}}
source={source}
defaultSource={genericIcon}
/>
</View>
);
@@ -99,11 +77,5 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
borderTopLeftRadius: 2,
borderBottomLeftRadius: 2,
},
icon: {
borderTopLeftRadius: 2,
borderBottomLeftRadius: 2,
},
});

View File

@@ -4,19 +4,18 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
ActivityIndicator,
Animated,
View,
Image,
StyleSheet,
} from 'react-native';
import {Client4} from 'mattermost-redux/client';
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 imageIcon from 'assets/images/icons/image.png';
import thumb from 'assets/images/thumb.png';
const {View: AnimatedView} = Animated;
const IMAGE_SIZE = {
Fullsize: 'fullsize',
@@ -26,7 +25,9 @@ const IMAGE_SIZE = {
export default class FileAttachmentImage extends PureComponent {
static propTypes = {
file: PropTypes.object.isRequired,
addFileToFetchCache: PropTypes.func.isRequired,
fetchCache: PropTypes.object.isRequired,
file: PropTypes.object,
imageHeight: PropTypes.number,
imageSize: PropTypes.oneOf([
IMAGE_SIZE.Fullsize,
@@ -34,44 +35,84 @@ export default class FileAttachmentImage extends PureComponent {
IMAGE_SIZE.Thumbnail,
]),
imageWidth: PropTypes.number,
onCaptureRef: PropTypes.func,
onCapturePreviewRef: PropTypes.func,
loadingBackgroundColor: PropTypes.string,
resizeMode: PropTypes.string,
resizeMethod: PropTypes.string,
wrapperBackgroundColor: PropTypes.string,
wrapperHeight: PropTypes.number,
wrapperWidth: PropTypes.number,
};
static defaultProps = {
fadeInOnLoad: false,
imageHeight: 80,
imageHeight: 100,
imageSize: IMAGE_SIZE.Preview,
imageWidth: 80,
imageWidth: 100,
loading: false,
loadingBackgroundColor: '#fff',
resizeMode: 'cover',
resizeMethod: 'resize',
wrapperHeight: 80,
wrapperWidth: 80,
wrapperBackgroundColor: '#fff',
wrapperHeigh: 100,
wrapperWidth: 100,
};
constructor(props) {
super(props);
state = {
opacity: new Animated.Value(0),
requesting: true,
retry: 0,
};
const {file} = props;
if (file && file.id) {
ImageCacheManager.cache(file.name, Client4.getFileThumbnailUrl(file.id), emptyFunction);
// Sometimes the request after a file upload errors out.
// We'll up to three times to get the image.
// We have to add a timestamp so fetch will retry the call.
handleLoadError = () => {
if (this.state.retry < 4) {
setTimeout(() => {
this.setState({
retry: (this.state.retry + 1),
timestamp: Date.now(),
});
}, 300);
}
};
if (isGif(file)) {
ImageCacheManager.cache(file.name, Client4.getFileUrl(file.id), emptyFunction);
}
handleLoad = () => {
this.setState({
requesting: false,
});
Animated.timing(this.state.opacity, {
toValue: 1,
duration: 300,
}).start(() => {
this.props.addFileToFetchCache(this.handleGetImageURL());
});
};
handleLoadStart = () => {
this.setState({
requesting: true,
});
};
handleGetImageURL = () => {
const {file, imageSize} = this.props;
if (file.localPath && this.state.retry === 0) {
return file.localPath;
}
this.state = {
opacity: new Animated.Value(0),
requesting: true,
retry: 0,
};
}
switch (imageSize) {
case IMAGE_SIZE.Fullsize:
return Client4.getFileUrl(file.id, this.state.timestamp);
case IMAGE_SIZE.Preview:
return Client4.getFilePreviewUrl(file.id, this.state.timestamp);
case IMAGE_SIZE.Thumbnail:
default:
return Client4.getFileThumbnailUrl(file.id, this.state.timestamp);
}
};
calculateNeededWidth = (height, width, newHeight) => {
const ratio = width / height;
@@ -84,66 +125,65 @@ export default class FileAttachmentImage extends PureComponent {
return newWidth;
};
handleCaptureRef = (ref) => {
const {onCaptureRef} = this.props;
if (onCaptureRef) {
onCaptureRef(ref);
}
};
handleCapturePreviewRef = (ref) => {
const {onCapturePreviewRef} = this.props;
if (onCapturePreviewRef) {
onCapturePreviewRef(ref);
}
};
render() {
const {
fetchCache,
file,
imageHeight,
imageWidth,
imageSize,
loadingBackgroundColor,
resizeMethod,
resizeMode,
wrapperBackgroundColor,
wrapperHeight,
wrapperWidth,
} = this.props;
let source = {};
if (this.state.retry === 4) {
source = imageIcon;
} else if (file.id) {
source = {uri: this.handleGetImageURL()};
} else if (file.failed) {
source = {uri: file.localPath};
}
const isInFetchCache = fetchCache[source.uri];
const imageComponentLoaders = {
onError: isInFetchCache ? null : this.handleLoadError,
onLoadStart: isInFetchCache ? null : this.handleLoadStart,
onLoad: isInFetchCache ? null : this.handleLoad,
};
const opacity = isInFetchCache ? 1 : this.state.opacity;
let height = imageHeight;
let width = imageWidth;
let imageStyle = {height, width};
if (imageSize === IMAGE_SIZE.Preview) {
height = 100;
width = this.calculateNeededWidth(file.height, file.width, height) || 100;
imageStyle = {height, width, position: 'absolute', top: 0, left: 0, borderBottomLeftRadius: 2, borderTopLeftRadius: 2};
}
const imageProps = {};
if (file.localPath) {
imageProps.defaultSource = {uri: file.localPath};
} else {
imageProps.thumbnailUri = Client4.getFileThumbnailUrl(file.id);
imageProps.imageUri = Client4.getFilePreviewUrl(file.id);
width = this.calculateNeededWidth(file.height, file.width, height);
imageStyle = {height, width, position: 'absolute', top: 0, left: 0};
}
return (
<View
ref={this.handleCaptureRef}
style={[style.fileImageWrapper, {height: wrapperHeight, width: wrapperWidth, overflow: 'hidden'}]}
>
<ProgressiveImage
ref={this.handleCapturePreviewRef}
style={imageStyle}
defaultSource={thumb}
tintDefaultSource={!file.localPath}
filename={file.name}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
{...imageProps}
/>
<View style={[style.fileImageWrapper, {backgroundColor: wrapperBackgroundColor, height: wrapperHeight, width: wrapperWidth, overflow: 'hidden'}]}>
<AnimatedView style={{height: imageHeight, width: imageWidth, backgroundColor: wrapperBackgroundColor, opacity}}>
<Image
style={imageStyle}
source={source}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
{...imageComponentLoaders}
/>
</AnimatedView>
{(!isInFetchCache && !file.failed && (file.loading || this.state.requesting)) &&
<View style={[style.loaderContainer, {backgroundColor: loadingBackgroundColor}]}>
<ActivityIndicator size='small'/>
</View>
}
</View>
);
}
@@ -153,8 +193,6 @@ const style = StyleSheet.create({
fileImageWrapper: {
alignItems: 'center',
justifyContent: 'center',
borderBottomLeftRadius: 2,
borderTopLeftRadius: 2,
},
loaderContainer: {
position: 'absolute',

View File

@@ -5,27 +5,19 @@ import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {
Keyboard,
Platform,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
TouchableOpacity,
} from 'react-native';
import {Client4} from 'mattermost-redux/client';
import {RequestStatus} from 'mattermost-redux/constants';
import {isDocument, isGif, isVideo} from 'app/utils/file';
import {getCacheFile} from 'app/utils/image_cache_manager';
import {preventDoubleTap} from 'app/utils/tap';
import FileAttachment from './file_attachment';
export default class FileAttachmentList extends Component {
static propTypes = {
actions: PropTypes.object.isRequired,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
fetchCache: PropTypes.object.isRequired,
fileIds: PropTypes.array.isRequired,
files: PropTypes.array.isRequired,
hideOptionsContext: PropTypes.func.isRequired,
@@ -39,30 +31,11 @@ export default class FileAttachmentList extends Component {
filesForPostRequest: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.items = [];
this.previewItems = [];
this.buildGalleryFiles(props).then((results) => {
this.galleryFiles = results;
});
}
componentDidMount() {
const {postId} = this.props;
this.props.actions.loadFilesForPostIfNecessary(postId);
}
componentWillReceiveProps(nextProps) {
if (this.props.files !== nextProps.files) {
this.buildGalleryFiles(nextProps).then((results) => {
this.galleryFiles = results;
});
}
}
componentDidUpdate() {
const {fileIds, files, filesForPostRequest, postId} = this.props;
@@ -72,121 +45,34 @@ export default class FileAttachmentList extends Component {
}
}
buildGalleryFiles = async (props) => {
const {files} = props;
const results = [];
if (files && files.length) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const caption = file.name;
if (isDocument(file) || isVideo(file) || (!file.has_preview_image && !isGif(file))) {
results.push({
caption,
data: file,
});
continue;
}
let uri;
let cache;
if (file.localPath) {
uri = file.localPath;
} else if (isGif(file)) {
cache = await getCacheFile(file.name, Client4.getFileUrl(file.id));
} else {
cache = await getCacheFile(file.name, Client4.getFilePreviewUrl(file.id));
}
if (cache) {
let path = cache.path;
if (Platform.OS === 'android') {
path = `file://${path}`;
}
uri = path;
}
results.push({
caption,
source: {uri},
data: file,
});
}
}
return results;
};
getItemMeasures = (index, cb) => {
const activeComponent = this.items[index];
if (!activeComponent) {
cb(null);
return;
}
activeComponent.measure((rx, ry, width, height, x, y) => {
cb({
origin: {x, y, width, height},
});
});
};
getPreviewProps = (index) => {
const previewComponent = this.previewItems[index];
return previewComponent ? {...previewComponent.props} : {};
};
goToImagePreview = (passProps) => {
goToImagePreview = (postId, fileId) => {
this.props.navigator.showModal({
screen: 'ImagePreview',
title: '',
animationType: 'none',
passProps,
passProps: {
fileId,
postId,
},
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
screenBackgroundColor: 'black',
modalPresentationStyle: 'overCurrentContext',
},
});
};
handleCaptureRef = (ref, idx) => {
this.items[idx] = ref;
};
handleCapturePreviewRef = (ref, idx) => {
this.previewItems[idx] = ref;
};
handleInfoPress = () => {
this.props.hideOptionsContext();
this.props.onPress();
};
handlePreviewPress = preventDoubleTap((idx) => {
handlePreviewPress = preventDoubleTap((file) => {
this.props.hideOptionsContext();
Keyboard.dismiss();
const component = this.items[idx];
if (!component) {
return;
}
component.measure((rx, ry, width, height, x, y) => {
this.goToImagePreview({
index: idx,
origin: {x, y, width, height},
target: {x: 0, y: 0, opacity: 1},
files: this.galleryFiles,
getItemMeasures: this.getItemMeasures,
getPreviewProps: this.getPreviewProps,
});
});
this.goToImagePreview(this.props.postId, file.id);
});
handlePressIn = () => {
@@ -197,28 +83,22 @@ export default class FileAttachmentList extends Component {
this.props.toggleSelected(false);
};
renderItems = () => {
const {deviceWidth, fileIds, files, navigator} = this.props;
render() {
const {fileIds, files, isFailed, navigator} = this.props;
let fileAttachments;
if (!files.length && fileIds.length > 0) {
return fileIds.map((id, idx) => (
fileAttachments = fileIds.map((id) => (
<FileAttachment
key={id}
deviceWidth={deviceWidth}
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={{loading: true}}
index={idx}
theme={this.props.theme}
/>
));
}
return files.map((file, idx) => {
const f = {
caption: file.name,
data: file,
};
return (
} else {
fileAttachments = files.map((file) => (
<TouchableOpacity
key={file.id}
onLongPress={this.props.onLongPress}
@@ -226,43 +106,22 @@ export default class FileAttachmentList extends Component {
onPressOut={this.handlePressOut}
>
<FileAttachment
deviceWidth={deviceWidth}
file={f}
index={idx}
navigator={navigator}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
onInfoPress={this.handleInfoPress}
onPreviewPress={this.handlePreviewPress}
theme={this.props.theme}
/>
</TouchableOpacity>
);
});
};
render() {
const {fileIds, isFailed} = this.props;
));
}
return (
<View style={styles.flex}>
<ScrollView
horizontal={true}
scrollEnabled={fileIds.length > 1}
style={[styles.flex, (isFailed && styles.failed)]}
>
{this.renderItems()}
</ScrollView>
<View style={[{flex: 1}, (isFailed && {opacity: 0.5})]}>
{fileAttachments}
</View>
);
}
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
failed: {
opacity: 0.5,
},
});

View File

@@ -5,10 +5,9 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {loadFilesForPostIfNecessary} from 'app/actions/views/channel';
import {getDimensions} from 'app/selectors/device';
import {addFileToFetchCache} from 'app/actions/views/file_preview';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import FileAttachmentList from './file_attachment_list';
@@ -16,7 +15,7 @@ function makeMapStateToProps() {
const getFilesForPost = makeGetFilesForPost();
return function mapStateToProps(state, ownProps) {
return {
...getDimensions(state),
fetchCache: state.views.fetchCache,
files: getFilesForPost(state, ownProps.postId),
theme: getTheme(state),
filesForPostRequest: state.requests.files.getFilesForPost,
@@ -27,6 +26,7 @@ function makeMapStateToProps() {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addFileToFetchCache,
loadFilesForPostIfNecessary,
}, dispatch),
};

View File

@@ -1,283 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Platform, StyleSheet, Text, View} from 'react-native';
import RNFetchBlob from 'react-native-fetch-blob';
import {AnimatedCircularProgress} from 'react-native-circular-progress';
import {Client4} from 'mattermost-redux/client';
import FileAttachmentImage from 'app/components/file_attachment_list/file_attachment_image';
import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon';
import FileUploadRetry from 'app/components/file_upload_preview/file_upload_retry';
import FileUploadRemove from 'app/components/file_upload_preview/file_upload_remove';
import {buildFileUploadData, encodeHeaderURIStringToUTF8} from 'app/utils/file';
export default class FileUploadItem extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
handleRemoveFile: PropTypes.func.isRequired,
retryFileUpload: PropTypes.func.isRequired,
uploadComplete: PropTypes.func.isRequired,
uploadFailed: PropTypes.func.isRequired,
}).isRequired,
channelId: PropTypes.string.isRequired,
file: PropTypes.object.isRequired,
rootId: PropTypes.string,
theme: PropTypes.object.isRequired,
};
state = {
progress: 0,
};
componentDidMount() {
if (this.props.file.loading) {
this.uploadFile();
}
}
componentWillReceiveProps(nextProps) {
const {file} = this.props;
const {file: nextFile} = nextProps;
if (file.failed !== nextFile.failed && nextFile.loading) {
this.uploadFile();
}
}
handleRetryFileUpload = (file) => {
if (!file.failed) {
return;
}
this.props.actions.retryFileUpload(file, this.props.rootId);
};
handleRemoveFile = (clientId, channelId, rootId) => {
const {handleRemoveFile} = this.props.actions;
if (this.uploadPromise) {
this.uploadPromise.cancel(() => {
this.canceled = true;
handleRemoveFile(clientId, channelId, rootId);
});
} else {
handleRemoveFile(clientId, channelId, rootId);
}
};
handleUploadCompleted = (res) => {
const {actions, channelId, file, rootId} = this.props;
const response = JSON.parse(res.data);
if (res.respInfo.status === 200 || res.respInfo.status === 201) {
this.setState({progress: 100}, () => {
const data = response.file_infos.map((f) => {
return {
...f,
clientId: file.clientId,
};
});
actions.uploadComplete(data, channelId, rootId);
});
} else {
actions.uploadFailed([file.clientId], channelId, rootId, response.message);
}
this.uploadPromise = null;
};
handleUploadError = (error) => {
const {actions, channelId, file, rootId} = this.props;
if (!this.canceled) {
actions.uploadFailed([file.clientId], channelId, rootId, error);
}
this.uploadPromise = null;
};
handleUploadProgress = (loaded, total) => {
this.setState({progress: Math.floor((loaded / total) * 100)});
};
isImageType = () => {
const {file} = this.props;
if (file.has_preview_image || file.mime_type === 'image/gif' ||
(file.localPath && file.type && file.type.includes('image'))) {
return true;
}
return false;
};
uploadFile = () => {
const {channelId, file} = this.props;
const fileData = buildFileUploadData(file);
const headers = {
Authorization: `Bearer ${Client4.getToken()}`,
'Content-Type': 'multipart/form-data',
};
const fileInfo = {
name: 'files',
filename: encodeHeaderURIStringToUTF8(fileData.name),
data: RNFetchBlob.wrap(file.localPath.replace('file://', '')),
type: fileData.type,
};
const data = [
{name: 'channel_id', data: channelId},
{name: 'client_ids', data: file.clientId},
fileInfo,
];
Client4.trackEvent('api', 'api_files_upload');
this.uploadPromise = RNFetchBlob.fetch('POST', Client4.getFilesRoute(), headers, data);
this.uploadPromise.uploadProgress(this.handleUploadProgress);
this.uploadPromise.then(this.handleUploadCompleted).catch(this.handleUploadError);
};
renderProgress = (fill) => {
const realFill = Number(fill.toFixed(0));
return (
<View style={styles.progressContent}>
<View style={styles.progressCirclePercentage}>
<Text style={styles.progressText}>
{`${realFill}%`}
</Text>
</View>
</View>
);
};
render() {
const {
channelId,
file,
rootId,
theme,
} = this.props;
const {progress} = this.state;
let filePreviewComponent;
if (this.isImageType()) {
filePreviewComponent = (
<FileAttachmentImage
file={file}
imageHeight={100}
imageWidth={100}
wrapperHeight={100}
wrapperWidth={100}
/>
);
} else {
filePreviewComponent = (
<FileAttachmentIcon
file={file}
theme={theme}
imageHeight={100}
imageWidth={100}
wrapperHeight={100}
wrapperWidth={100}
/>
);
}
return (
<View
key={file.clientId}
style={styles.preview}
>
<View style={styles.previewShadow}>
{filePreviewComponent}
{file.failed &&
<FileUploadRetry
file={file}
onPress={this.handleRetryFileUpload}
/>
}
{file.loading && !file.failed &&
<View style={styles.progressCircleContent}>
<AnimatedCircularProgress
size={100}
fill={progress}
width={4}
backgroundColor='rgba(255, 255, 255, 0.5)'
tintColor='white'
rotation={0}
style={styles.progressCircle}
>
{this.renderProgress}
</AnimatedCircularProgress>
</View>
}
</View>
<FileUploadRemove
channelId={channelId}
clientId={file.clientId}
onPress={this.handleRemoveFile}
rootId={rootId}
/>
</View>
);
}
}
const styles = StyleSheet.create({
preview: {
justifyContent: 'flex-end',
height: 115,
width: 115,
},
previewShadow: {
height: 100,
width: 100,
elevation: 10,
...Platform.select({
ios: {
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.5,
shadowRadius: 4,
shadowOffset: {
width: 0,
height: 0,
},
},
}),
},
progressCircle: {
alignItems: 'center',
height: '100%',
justifyContent: 'center',
width: '100%',
},
progressCircleContent: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
height: 100,
justifyContent: 'center',
position: 'absolute',
width: 100,
},
progressCirclePercentage: {
alignItems: 'center',
flex: 1,
},
progressContent: {
alignItems: 'center',
height: '100%',
justifyContent: 'center',
left: 0,
position: 'absolute',
top: 40,
width: '100%',
},
progressText: {
color: 'white',
fontSize: 18,
},
});

View File

@@ -1,30 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {handleRemoveFile, retryFileUpload, uploadComplete, uploadFailed} from 'app/actions/views/file_upload';
import FileUploadItem from './file_upload_item';
function mapStateToProps(state) {
return {
theme: getTheme(state),
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
handleRemoveFile,
retryFileUpload,
uploadComplete,
uploadFailed,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(FileUploadItem);

View File

@@ -4,39 +4,97 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import FormattedText from 'app/components/formatted_text';
import FileUploadItem from './file_upload_item';
import FileAttachmentImage from 'app/components/file_attachment_list/file_attachment_image';
import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon';
export default class FileUploadPreview extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
addFileToFetchCache: PropTypes.func.isRequired,
handleRemoveFile: PropTypes.func.isRequired,
retryFileUpload: PropTypes.func.isRequired,
}).isRequired,
channelId: PropTypes.string.isRequired,
channelIsLoading: PropTypes.bool,
createPostRequestStatus: PropTypes.string.isRequired,
deviceHeight: PropTypes.number.isRequired,
fetchCache: PropTypes.object.isRequired,
files: PropTypes.array.isRequired,
filesUploadingForCurrentChannel: PropTypes.bool.isRequired,
inputHeight: PropTypes.number.isRequired,
rootId: PropTypes.string,
showFileMaxWarning: PropTypes.bool.isRequired,
theme: PropTypes.object.isRequired,
filesUploadingForCurrentChannel: PropTypes.bool.isRequired,
showFileMaxWarning: PropTypes.bool.isRequired,
};
handleRetryFileUpload = (file) => {
if (!file.failed) {
return;
}
this.props.actions.retryFileUpload(file, this.props.rootId);
};
buildFilePreviews = () => {
return this.props.files.map((file) => {
let filePreviewComponent;
if (file.loading | (file.has_preview_image || file.mime_type === 'image/gif')) {
filePreviewComponent = (
<FileAttachmentImage
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
/>
);
} else {
filePreviewComponent = (
<FileAttachmentIcon
file={file}
theme={this.props.theme}
/>
);
}
return (
<FileUploadItem
<View
key={file.clientId}
channelId={this.props.channelId}
file={file}
rootId={this.props.rootId}
theme={this.props.theme}
/>
style={style.preview}
>
<View style={style.previewShadow}>
{filePreviewComponent}
{file.failed &&
<TouchableOpacity
style={style.failed}
onPress={() => this.handleRetryFileUpload(file)}
>
<Icon
name='md-refresh'
size={50}
color='#fff'
/>
</TouchableOpacity>
}
</View>
<TouchableOpacity
style={style.removeButtonWrapper}
onPress={() => this.props.actions.handleRemoveFile(file.clientId, this.props.channelId, this.props.rootId)}
>
<Icon
name='md-close'
color='#fff'
size={18}
style={style.removeButtonIcon}
/>
</TouchableOpacity>
</View>
);
});
};
@@ -85,9 +143,62 @@ const style = StyleSheet.create({
position: 'absolute',
width: '100%',
},
failed: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
position: 'absolute',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
},
preview: {
justifyContent: 'flex-end',
height: 115,
width: 115,
},
previewShadow: {
height: 100,
width: 100,
elevation: 10,
...Platform.select({
ios: {
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.5,
shadowRadius: 4,
shadowOffset: {
width: 0,
height: 0,
},
},
}),
},
removeButtonIcon: Platform.select({
ios: {
marginTop: 2,
},
android: {
marginLeft: 1,
},
}),
removeButtonWrapper: {
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
overflow: 'hidden',
elevation: 11,
top: 7,
right: 7,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#000',
borderWidth: 1,
borderColor: '#fff',
},
scrollView: {
flex: 1,
marginBottom: 10,
marginBottom: 12,
},
scrollViewContent: {
alignItems: 'flex-end',
@@ -96,6 +207,6 @@ const style = StyleSheet.create({
warning: {
color: 'white',
marginLeft: 14,
marginBottom: 10,
marginBottom: 12,
},
});

View File

@@ -1,64 +0,0 @@
// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Platform, StyleSheet, TouchableOpacity} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
export default class FileUploadRemove extends PureComponent {
static propTypes = {
channelId: PropTypes.string,
clientId: PropTypes.string,
onPress: PropTypes.func.isRequired,
rootId: PropTypes.string,
};
handleOnPress = () => {
const {channelId, clientId, onPress, rootId} = this.props;
onPress(clientId, channelId, rootId);
};
render() {
return (
<TouchableOpacity
style={style.removeButtonWrapper}
onPress={this.handleOnPress}
>
<Icon
name='md-close'
color='#fff'
size={18}
style={style.removeButtonIcon}
/>
</TouchableOpacity>
);
}
}
const style = StyleSheet.create({
removeButtonIcon: Platform.select({
ios: {
marginTop: 2,
},
android: {
marginLeft: 1,
},
}),
removeButtonWrapper: {
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
overflow: 'hidden',
elevation: 11,
top: 7,
right: 7,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#000',
borderWidth: 1,
borderColor: '#fff',
},
});

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {StyleSheet, TouchableOpacity} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
export default class FileUploadRetry extends PureComponent {
static propTypes = {
file: PropTypes.object.isRequired,
onPress: PropTypes.func.isRequired,
};
handleOnPress = () => {
const {file, onPress} = this.props;
onPress(file);
};
render() {
return (
<TouchableOpacity
style={style.failed}
onPress={this.handleOnPress}
>
<Icon
name='md-refresh'
size={50}
color='#fff'
/>
</TouchableOpacity>
);
}
}
const style = StyleSheet.create({
failed: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
position: 'absolute',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -1,14 +1,34 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {handleRemoveFile, retryFileUpload} from 'app/actions/views/file_upload';
import {addFileToFetchCache} from 'app/actions/views/file_preview';
import {getDimensions} from 'app/selectors/device';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getDimensions} from 'app/selectors/device';
import {checkForFileUploadingInChannel} from 'app/selectors/file';
import FileUploadPreview from './file_upload_preview';
const checkForFileUploadingInChannel = createSelector(
(state, channelId, rootId) => {
if (rootId) {
return state.views.thread.drafts[rootId];
}
return state.views.channel.drafts[channelId];
},
(draft) => {
if (!draft || !draft.files) {
return false;
}
return draft.files.some((f) => f.loading);
}
);
function mapStateToProps(state, ownProps) {
const {deviceHeight} = getDimensions(state);
@@ -16,9 +36,20 @@ function mapStateToProps(state, ownProps) {
channelIsLoading: state.views.channel.loading,
createPostRequestStatus: state.requests.posts.createPost.status,
deviceHeight,
fetchCache: state.views.fetchCache,
filesUploadingForCurrentChannel: checkForFileUploadingInChannel(state, ownProps.channelId, ownProps.rootId),
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(FileUploadPreview);
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addFileToFetchCache,
handleRemoveFile,
retryFileUpload,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(FileUploadPreview);

View File

@@ -151,7 +151,6 @@ export default class Markdown extends PureComponent {
return (
<MarkdownImage
linkDestination={linkDestination}
navigator={this.props.navigator}
onLongPress={this.props.onLongPress}
source={src}
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}

View File

@@ -12,15 +12,13 @@ import {
StyleSheet,
Text,
TouchableHighlight,
TouchableWithoutFeedback,
View,
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import ProgressiveImage from 'app/components/progressive_image';
import CustomPropTypes from 'app/constants/custom_prop_types';
import mattermostManaged from 'app/mattermost_managed';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {normalizeProtocol} from 'app/utils/url';
const MAX_IMAGE_HEIGHT = 150;
@@ -32,7 +30,6 @@ export default class MarkdownImage extends React.Component {
static propTypes = {
children: PropTypes.node,
linkDestination: PropTypes.string,
navigator: PropTypes.object.isRequired,
onLongPress: PropTypes.func,
serverURL: PropTypes.string.isRequired,
source: PropTypes.string.isRequired,
@@ -48,17 +45,16 @@ export default class MarkdownImage extends React.Component {
this.state = {
width: 0,
height: MAX_IMAGE_HEIGHT,
height: 0,
maxWidth: Math.MAX_INT,
failed: false,
uri: null,
};
this.mounted = false;
}
componentWillMount() {
ImageCacheManager.cache(null, this.getSource(), this.setImageUrl);
this.loadImageSize(this.getSource());
}
componentDidMount() {
@@ -74,7 +70,7 @@ export default class MarkdownImage extends React.Component {
});
// getSource also depends on serverURL, but that shouldn't change while this is mounted
ImageCacheManager.cache(null, this.getSource(nextProps), this.setImageUrl);
this.loadImageSize(this.getSource(nextProps));
}
}
@@ -82,26 +78,6 @@ export default class MarkdownImage extends React.Component {
this.mounted = false;
}
getItemMeasures = (index, cb) => {
const activeComponent = this.refs.item;
if (!activeComponent) {
cb(null);
return;
}
activeComponent.measure((rx, ry, width, height, x, y) => {
cb({
origin: {x, y, width, height},
});
});
};
getPreviewProps = () => {
const previewComponent = this.refs.image;
return previewComponent ? {...previewComponent.props} : {};
};
getSource = (props = this.props) => {
let source = props.source;
@@ -112,20 +88,8 @@ export default class MarkdownImage extends React.Component {
return source;
};
goToImagePreview = (passProps) => {
this.props.navigator.showModal({
screen: 'ImagePreview',
title: '',
animationType: 'none',
passProps,
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
modalPresentationStyle: 'overCurrentContext',
},
});
loadImageSize = (source) => {
Image.getSize(source, this.handleSizeReceived, this.handleSizeFailed);
};
handleSizeReceived = (width, height) => {
@@ -189,61 +153,8 @@ export default class MarkdownImage extends React.Component {
Clipboard.setString(this.props.linkDestination);
};
handlePreviewImage = () => {
const component = this.refs.item;
if (!component) {
return;
}
component.measure((rx, ry, width, height, x, y) => {
const {uri} = this.state;
const link = this.getSource();
let filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?'));
const extension = filename.split('.').pop();
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
}
const files = [{
caption: filename,
source: {uri},
data: {
localPath: uri,
},
}];
this.goToImagePreview({
index: 0,
origin: {x, y, width, height},
target: {x: 0, y: 0, opacity: 1},
files,
getItemMeasures: this.getItemMeasures,
getPreviewProps: this.getPreviewProps,
});
});
};
loadImageSize = (source) => {
Image.getSize(source, this.handleSizeReceived, this.handleSizeFailed);
};
setImageUrl = (imageURL) => {
let uri = imageURL;
if (Platform.OS === 'android') {
uri = `file://${imageURL}`;
}
this.setState({uri});
this.loadImageSize(uri);
};
render() {
let image = null;
const {uri} = this.state;
if (this.state.width && this.state.height && this.state.maxWidth) {
let {width, height} = this.state;
@@ -279,24 +190,12 @@ export default class MarkdownImage extends React.Component {
}
// React Native complains if we try to pass resizeMode as a style
let source = null;
if (uri) {
source = {uri};
}
image = (
<TouchableWithoutFeedback
onLongPress={this.handleLinkLongPress}
onPress={this.handlePreviewImage}
style={{width, height}}
>
<ProgressiveImage
ref='image'
defaultSource={source}
resizeMode='contain'
style={[{width, height}, style.image]}
/>
</TouchableWithoutFeedback>
<Image
source={{uri: this.getSource()}}
resizeMode='contain'
style={[{width, height}, style.image]}
/>
);
}
} else if (this.state.failed) {
@@ -325,8 +224,7 @@ export default class MarkdownImage extends React.Component {
return (
<View
ref='item'
style={[style.container, {height: Math.min(this.state.height, MAX_IMAGE_HEIGHT)}]}
style={style.container}
onLayout={this.handleLayout}
>
{image}

View File

@@ -1,8 +1,9 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import PropTypes from 'prop-types';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {StyleSheet, View} from 'react-native';
export default class MarkdownList extends PureComponent {
static propTypes = {
@@ -32,9 +33,15 @@ export default class MarkdownList extends PureComponent {
});
return (
<React.Fragment>
<View style={style.indent}>
{children}
</React.Fragment>
</View>
);
}
}
const style = StyleSheet.create({
indent: {
marginRight: 20,
},
});

View File

@@ -41,7 +41,7 @@ export default class MarkdownListItem extends PureComponent {
{bullet}
</Text>
</View>
<View style={style.contents}>
<View>
{this.props.children}
</View>
</View>
@@ -50,15 +50,12 @@ export default class MarkdownListItem extends PureComponent {
}
const style = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start',
},
bullet: {
alignItems: 'flex-end',
marginRight: 5,
},
contents: {
flex: 1,
container: {
flexDirection: 'row',
alignItems: 'flex-start',
},
});

View File

@@ -1,71 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Text, View} from 'react-native';
import IonIcon from 'react-native-vector-icons/Ionicons';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class NoResults extends PureComponent {
static propTypes = {
description: PropTypes.string,
iconName: PropTypes.string,
theme: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
};
render() {
const {
description,
iconName,
theme,
title,
} = this.props;
const style = getStyleFromTheme(theme);
let icon;
if (iconName) {
icon = (
<IonIcon
size={76}
color={changeOpacity(theme.centerChannelColor, 0.4)}
name={iconName}
/>
);
}
return (
<View style={style.container}>
{icon}
<Text style={style.title}>{title}</Text>
{description &&
<Text style={style.description}>{description}</Text>
}
</View>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
paddingHorizontal: 15,
},
title: {
color: changeOpacity(theme.centerChannelColor, 0.4),
fontSize: 20,
fontWeight: '600',
marginBottom: 15,
},
description: {
color: changeOpacity(theme.centerChannelColor, 0.4),
fontSize: 17,
textAlign: 'center',
},
};
});

View File

@@ -5,13 +5,11 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {createPost, deletePost, removePost} from 'mattermost-redux/actions/posts';
import {getCurrentChannelId, isCurrentChannelReadOnly} from 'mattermost-redux/selectors/entities/channels';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import {getMyPreferences, getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {canDeletePost, canEditPost, isPostFlagged} from 'mattermost-redux/utils/post_utils';
import {isAdmin as checkIsAdmin, isSystemAdmin as checkIsSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {getCurrentTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import {isPostFlagged} from 'mattermost-redux/utils/post_utils';
import {insertToDraft, setPostTooltipVisible} from 'app/actions/views/channel';
import {addReaction} from 'app/actions/views/emoji';
@@ -25,9 +23,6 @@ function mapStateToProps(state, ownProps) {
const {config, license} = state.entities.general;
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
const myPreferences = getMyPreferences(state);
const currentUserId = getCurrentUserId(state);
const currentTeamId = getCurrentTeamId(state);
const currentChannelId = getCurrentChannelId(state);
let isFirstReply = true;
let isLastReply = true;
@@ -56,29 +51,17 @@ function mapStateToProps(state, ownProps) {
const {deviceWidth} = getDimensions(state);
const isAdmin = checkIsAdmin(roles);
const isSystemAdmin = checkIsSystemAdmin(roles);
let canDelete = false;
let canEdit = false;
if (post) {
canDelete = canDeletePost(state, config, license, currentTeamId, currentChannelId, currentUserId, post, isAdmin, isSystemAdmin);
canEdit = canEditPost(state, config, license, currentTeamId, currentChannelId, currentUserId, post);
}
return {
channelIsReadOnly: isCurrentChannelReadOnly(state),
config,
canDelete,
canEdit,
currentTeamUrl: getCurrentTeamUrl(state),
currentUserId,
currentUserId: getCurrentUserId(state),
deviceWidth,
post,
isFirstReply,
isLastReply,
commentedOnPost,
license,
roles,
theme: getTheme(state),
isFlagged: isPostFlagged(post.id, myPreferences),
};

View File

@@ -26,7 +26,8 @@ import {getToolTipVisible} from 'app/utils/tooltip';
import {Posts} from 'mattermost-redux/constants';
import DelayedAction from 'mattermost-redux/utils/delayed_action';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {editDisable, isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {canDeletePost, canEditPost, isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {isAdmin, isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import Config from 'assets/config';
@@ -39,7 +40,6 @@ export default class Post extends PureComponent {
insertToDraft: PropTypes.func.isRequired,
removePost: PropTypes.func.isRequired,
}).isRequired,
channelIsReadOnly: PropTypes.bool,
config: PropTypes.object.isRequired,
currentTeamUrl: PropTypes.string.isRequired,
currentUserId: PropTypes.string.isRequired,
@@ -56,13 +56,10 @@ export default class Post extends PureComponent {
license: PropTypes.object.isRequired,
managedConfig: PropTypes.object.isRequired,
navigator: PropTypes.object,
canEdit: PropTypes.bool.isRequired,
canDelete: PropTypes.bool.isRequired,
onPermalinkPress: PropTypes.func,
roles: PropTypes.string,
shouldRenderReplyButton: PropTypes.bool,
showAddReaction: PropTypes.bool,
showFullDate: PropTypes.bool,
showLongPost: PropTypes.bool,
theme: PropTypes.object.isRequired,
onPress: PropTypes.func,
onReply: PropTypes.func,
@@ -71,9 +68,6 @@ export default class Post extends PureComponent {
static defaultProps = {
isSearchResult: false,
showAddReaction: true,
showLongPost: false,
channelIsReadOnly: false,
};
static contextTypes = {
@@ -83,26 +77,32 @@ export default class Post extends PureComponent {
constructor(props) {
super(props);
const {config, license, currentUserId, post} = props;
const {config, license, currentUserId, roles, post} = props;
this.editDisableAction = new DelayedAction(this.handleEditDisable);
if (post) {
editDisable(config, license, currentUserId, post, this.editDisableAction);
this.state = {
canEdit: canEditPost(config, license, currentUserId, post, this.editDisableAction),
canDelete: canDeletePost(config, license, currentUserId, post, isAdmin(roles), isSystemAdmin(roles)),
};
} else {
this.state = {
canEdit: false,
canDelete: false,
};
}
this.state = {
canEdit: this.props.canEdit,
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.config !== this.props.config ||
nextProps.license !== this.props.license ||
nextProps.currentUserId !== this.props.currentUserId ||
nextProps.post !== this.props.post) {
const {config, license, currentUserId, post} = nextProps;
nextProps.post !== this.props.post ||
nextProps.roles !== this.props.roles) {
const {config, license, currentUserId, roles, post} = nextProps;
editDisable(config, license, currentUserId, post, this.editDisableAction);
this.setState({
canEdit: nextProps.canEdit,
canEdit: canEditPost(config, license, currentUserId, post, this.editDisableAction),
canDelete: canDeletePost(config, license, currentUserId, post, isAdmin(roles), isSystemAdmin(roles)),
});
}
}
@@ -138,6 +138,8 @@ export default class Post extends PureComponent {
};
autofillUserMention = (username) => {
// create a general action that checks for currentThreadId in the state and decides
// whether to insert to root or thread
this.props.actions.insertToDraft(`@${username} `);
}
@@ -275,13 +277,12 @@ export default class Post extends PureComponent {
const {
onPress,
post,
showLongPost,
} = this.props;
if (!getToolTipVisible()) {
if (onPress && post.state !== Posts.POST_DELETED && !isSystemMessage(post) && !isPostPendingOrFailed(post)) {
onPress(post);
} else if ((isPostEphemeral(post) || post.state === Posts.POST_DELETED) && !showLongPost) {
} else if (isPostEphemeral(post) || post.state === Posts.POST_DELETED) {
this.onRemovePost(post);
}
} else if (this.refs.postBody) {
@@ -379,7 +380,6 @@ export default class Post extends PureComponent {
render() {
const {
channelIsReadOnly,
commentedOnPost,
highlight,
isLastReply,
@@ -388,9 +388,7 @@ export default class Post extends PureComponent {
post,
renderReplies,
shouldRenderReplyButton,
showAddReaction,
showFullDate,
showLongPost,
theme,
managedConfig,
isFlagged,
@@ -444,10 +442,8 @@ export default class Post extends PureComponent {
<View style={{maxWidth: postWidth}}>
<PostBody
ref={'postBody'}
canDelete={this.props.canDelete}
canDelete={this.state.canDelete}
canEdit={this.state.canEdit}
highlight={highlight}
channelIsReadOnly={channelIsReadOnly}
isSearchResult={isSearchResult}
navigator={this.props.navigator}
onAddReaction={this.handleAddReaction}
@@ -464,8 +460,6 @@ export default class Post extends PureComponent {
managedConfig={managedConfig}
isFlagged={isFlagged}
isReplyPost={isReplyPost}
showAddReaction={showAddReaction}
showLongPost={showLongPost}
/>
</View>
</View>
@@ -478,6 +472,7 @@ export default class Post extends PureComponent {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
},
pendingPost: {

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {addChannelMember} from 'mattermost-redux/actions/channels';
import {removePost} from 'mattermost-redux/actions/posts';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {sendAddToChannelEphemeralPost} from 'app/actions/views/post';
import PostAddChannelMember from './post_add_channel_member';
function mapStateToProps(state, ownProps) {
const post = getPost(state, ownProps.postId) || {};
let channelType = '';
if (post && post.channel_id) {
const channel = getChannel(state, post.channel_id);
if (channel && channel.type) {
channelType = channel.type;
}
}
return {
channelType,
currentUser: getCurrentUser(state),
post,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addChannelMember,
removePost,
sendAddToChannelEphemeralPost,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(PostAddChannelMember);

View File

@@ -1,189 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {intlShape} from 'react-intl';
import {Text} from 'react-native';
import {General} from 'mattermost-redux/constants';
import {concatStyles} from 'app/utils/theme';
import AtMention from 'app/components/at_mention';
import FormattedText from 'app/components/formatted_text';
export default class PostAddChannelMember extends React.PureComponent {
static propTypes = {
actions: PropTypes.shape({
addChannelMember: PropTypes.func.isRequired,
removePost: PropTypes.func.isRequired,
sendAddToChannelEphemeralPost: PropTypes.func.isRequired,
}).isRequired,
currentUser: PropTypes.object.isRequired,
channelType: PropTypes.string,
post: PropTypes.object.isRequired,
postId: PropTypes.string.isRequired,
userIds: PropTypes.array.isRequired,
usernames: PropTypes.array.isRequired,
navigator: PropTypes.object.isRequired,
onLongPress: PropTypes.func,
onPostPress: PropTypes.func,
textStyles: PropTypes.object,
};
static contextTypes = {
intl: intlShape,
};
computeTextStyle = (baseStyle, context) => {
return concatStyles(baseStyle, context.map((type) => this.props.textStyles[type]));
}
handleAddChannelMember = () => {
const {
actions,
currentUser,
post,
userIds,
usernames,
} = this.props;
const {formatMessage} = this.context.intl;
if (post && post.channel_id) {
userIds.forEach((userId, index) => {
actions.addChannelMember(post.channel_id, userId);
if (post.root_id) {
const message = formatMessage(
{
id: 'api.channel.add_member.added',
defaultMessage: '{addedUsername} added to the channel by {username}.',
},
{
username: currentUser.username,
addedUsername: usernames[index],
}
);
actions.sendAddToChannelEphemeralPost(currentUser, usernames[index], message, post.channel_id, post.root_id);
}
});
actions.removePost(post);
}
}
generateAtMentions(usernames = []) {
if (usernames.length === 1) {
return (
<AtMention
mentionStyle={this.props.textStyles.mention}
mentionName={usernames[0]}
onLongPress={this.props.onLongPress}
onPostPress={this.props.onPostPress}
navigator={this.props.navigator}
/>
);
} else if (usernames.length > 1) {
function andSeparator(key) {
return (
<FormattedText
key={key}
id={'post_body.check_for_out_of_channel_mentions.link.and'}
defaultMessage={' and '}
/>
);
}
function commaSeparator(key) {
return <Text key={key}>{', '}</Text>;
}
return (
<Text>
{
usernames.map((username) => {
return (
<AtMention
key={username}
mentionStyle={this.props.textStyles.mention}
mentionName={username}
onLongPress={this.props.onLongPress}
onPostPress={this.props.onPostPress}
navigator={this.props.navigator}
/>
);
}).reduce((acc, el, idx, arr) => {
if (idx === 0) {
return [el];
} else if (idx === arr.length - 1) {
return [...acc, andSeparator(idx), el];
}
return [...acc, commaSeparator(idx), el];
}, [])
}
</Text>
);
}
return '';
}
render() {
const {channelType, postId, usernames} = this.props;
if (!postId || !channelType) {
return null;
}
let linkId;
let linkText;
if (channelType === General.PRIVATE_CHANNEL) {
linkId = 'post_body.check_for_out_of_channel_mentions.link.private';
linkText = 'add them to this private channel';
} else if (channelType === General.OPEN_CHANNEL) {
linkId = 'post_body.check_for_out_of_channel_mentions.link.public';
linkText = 'add them to the channel';
}
let messageId;
let messageText;
if (usernames.length === 1) {
messageId = 'post_body.check_for_out_of_channel_mentions.message.one';
messageText = 'was mentioned but is not in the channel. Would you like to ';
} else if (usernames.length > 1) {
messageId = 'post_body.check_for_out_of_channel_mentions.message.multiple';
messageText = 'were mentioned but they are not in the channel. Would you like to ';
}
const atMentions = this.generateAtMentions(usernames);
return (
<Text>
{atMentions}
{' '}
<FormattedText
id={messageId}
defaultMessage={messageText}
/>
<Text
style={this.props.textStyles.link}
id='add_channel_member_link'
onPress={this.handleAddChannelMember}
>
<FormattedText
id={linkId}
defaultMessage={linkText}
/>
</Text>
<FormattedText
id={'post_body.check_for_out_of_channel_mentions.message_last'}
defaultMessage={'? They will have access to all message history.'}
/>
</Text>
);
}
}

View File

@@ -7,19 +7,19 @@ import {
Dimensions,
Image,
Linking,
Platform,
PixelRatio,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import ProgressiveImage from 'app/components/progressive_image';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {getNearestPoint} from 'app/utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const LARGE_IMAGE_MIN_WIDTH = 150;
const LARGE_IMAGE_MIN_RATIO = (16 / 9);
const MAX_IMAGE_HEIGHT = 150;
const THUMBNAIL_SIZE = 75;
export default class PostAttachmentOpenGraph extends PureComponent {
static propTypes = {
@@ -28,7 +28,6 @@ export default class PostAttachmentOpenGraph extends PureComponent {
}).isRequired,
isReplyPost: PropTypes.bool,
link: PropTypes.string.isRequired,
navigator: PropTypes.object.isRequired,
openGraphData: PropTypes.object,
theme: PropTypes.object.isRequired,
};
@@ -37,8 +36,8 @@ export default class PostAttachmentOpenGraph extends PureComponent {
super(props);
this.state = {
hasImage: false,
imageUrl: null,
imageLoaded: false,
hasLargeImage: false,
};
}
@@ -53,7 +52,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
componentWillReceiveProps(nextProps) {
if (nextProps.link !== this.props.link) {
this.setState({hasImage: false});
this.setState({imageLoaded: false});
this.fetchData(nextProps.link, nextProps.openGraphData);
}
@@ -87,6 +86,27 @@ export default class PostAttachmentOpenGraph extends PureComponent {
return {width: maxWidth, height: maxHeight};
};
calculateSmallImageDimensions = (width, height) => {
const {width: deviceWidth} = Dimensions.get('window');
const offset = deviceWidth - 170;
let ratio;
let maxWidth;
let maxHeight;
if (width >= height) {
ratio = width / height;
maxWidth = THUMBNAIL_SIZE;
maxHeight = PixelRatio.roundToNearestPixel(maxWidth / ratio);
} else {
ratio = height / width;
maxHeight = THUMBNAIL_SIZE;
maxWidth = PixelRatio.roundToNearestPixel(maxHeight / ratio);
}
return {width: maxWidth, height: maxHeight, offset};
};
fetchData(url, openGraphData) {
if (!openGraphData) {
this.props.actions.getOpenGraphMetadata(url);
@@ -102,126 +122,54 @@ export default class PostAttachmentOpenGraph extends PureComponent {
width: Dimensions.get('window').width - 88,
height: MAX_IMAGE_HEIGHT,
};
const bestImage = getNearestPoint(bestDimensions, data.images, 'width', 'height');
const imageUrl = bestImage.secure_url || bestImage.url;
this.setState({
hasImage: true,
...bestDimensions,
openGraphImageUrl: imageUrl,
});
if (imageUrl) {
ImageCacheManager.cache(null, imageUrl, this.getImageSize);
this.getImageSize(imageUrl);
}
}
getImageSize = (imageUrl) => {
let prefix = '';
if (Platform.OS === 'android') {
prefix = 'file://';
if (!this.state.imageLoaded) {
Image.getSize(imageUrl, (width, height) => {
const {hasLargeImage} = this.state;
const imageRatio = width / height;
let isLarge = false;
let dimensions;
if (width >= LARGE_IMAGE_MIN_WIDTH && imageRatio >= LARGE_IMAGE_MIN_RATIO && !hasLargeImage) {
isLarge = true;
dimensions = this.calculateLargeImageDimensions(width, height);
} else {
dimensions = this.calculateSmallImageDimensions(width, height);
}
if (this.mounted) {
this.setState({
...dimensions,
hasLargeImage: isLarge,
imageLoaded: true,
imageUrl,
});
}
}, () => null);
}
const uri = `${prefix}${imageUrl}`;
Image.getSize(uri, (width, height) => {
const dimensions = this.calculateLargeImageDimensions(width, height);
if (this.mounted) {
this.setState({
...dimensions,
imageUrl: uri,
});
}
}, () => null);
};
getItemMeasures = (index, cb) => {
const activeComponent = this.refs.item;
if (!activeComponent) {
cb(null);
return;
}
activeComponent.measure((rx, ry, width, height, x, y) => {
cb({
origin: {x, y, width, height},
});
});
};
getPreviewProps = () => {
const previewComponent = this.refs.image;
return previewComponent ? {...previewComponent.props} : {};
};
goToImagePreview = (passProps) => {
this.props.navigator.showModal({
screen: 'ImagePreview',
title: '',
animationType: 'none',
passProps,
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
modalPresentationStyle: 'overCurrentContext',
},
});
};
goToLink = () => {
Linking.openURL(this.props.link);
};
handlePreviewImage = () => {
const component = this.refs.item;
if (!component) {
return;
}
component.measure((rx, ry, width, height, x, y) => {
const {imageUrl: uri, openGraphImageUrl: link} = this.state;
let filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?'));
const extension = filename.split('.').pop();
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
}
const files = [{
caption: filename,
source: {uri},
data: {
localPath: uri,
},
}];
this.goToImagePreview({
index: 0,
origin: {x, y, width, height},
target: {x: 0, y: 0, opacity: 1},
files,
getItemMeasures: this.getItemMeasures,
getPreviewProps: this.getPreviewProps,
});
});
};
render() {
const {isReplyPost, openGraphData, theme} = this.props;
const {hasImage, height, imageUrl, width} = this.state;
const {hasLargeImage, height, imageLoaded, imageUrl, offset, width} = this.state;
if (!openGraphData || !openGraphData.description) {
return null;
}
const style = getStyleSheet(theme);
const isThumbnail = !hasLargeImage && imageLoaded;
return (
<View style={style.container}>
@@ -236,7 +184,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
</View>
<View style={style.wrapper}>
<TouchableOpacity
style={style.flex}
style={isThumbnail ? {width: offset} : style.flex}
onPress={this.goToLink}
>
<Text
@@ -247,6 +195,15 @@ export default class PostAttachmentOpenGraph extends PureComponent {
{openGraphData.title}
</Text>
</TouchableOpacity>
{isThumbnail &&
<View style={style.thumbnail}>
<Image
style={[style.image, {width, height}]}
source={{uri: imageUrl}}
resizeMode='cover'
/>
</View>
}
</View>
<View style={style.flex}>
<Text
@@ -257,20 +214,12 @@ export default class PostAttachmentOpenGraph extends PureComponent {
{openGraphData.description}
</Text>
</View>
{hasImage &&
<View ref='item'>
<TouchableWithoutFeedback
onPress={this.handlePreviewImage}
style={{width, height}}
>
<ProgressiveImage
ref='image'
style={[style.image, {width, height}]}
imageUri={imageUrl}
resizeMode='cover'
/>
</TouchableWithoutFeedback>
</View>
{hasLargeImage && imageLoaded &&
<Image
style={[style.image, {width, height}]}
source={{uri: imageUrl}}
resizeMode='cover'
/>
}
</View>
);
@@ -311,5 +260,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
image: {
borderRadius: 3,
},
thumbnail: {
flex: 1,
alignItems: 'flex-end',
justifyContent: 'flex-start',
},
};
});

View File

@@ -5,22 +5,10 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {flagPost, unflagPost} from 'mattermost-redux/actions/posts';
import {
General,
Posts,
} from 'mattermost-redux/constants';
import {getChannel, canManageChannelMembers} from 'mattermost-redux/selectors/entities/channels';
import {Posts} from 'mattermost-redux/constants';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {hasNewPermissions} from 'mattermost-redux/selectors/entities/general';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import Permissions from 'mattermost-redux/constants/permissions';
import {
isEdited,
isPostEphemeral,
isSystemMessage,
} from 'mattermost-redux/utils/post_utils';
import {isEdited, isPostEphemeral, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import PostBody from './post_body';
@@ -28,17 +16,6 @@ const POST_TIMEOUT = 20000;
function mapStateToProps(state, ownProps) {
const post = getPost(state, ownProps.postId);
const channel = getChannel(state, post.channel_id) || {};
const teamId = channel.team_id;
let canAddReaction = true;
if (hasNewPermissions(state)) {
canAddReaction = haveIChannelPermission(state, {
team: teamId,
channel: post.channel_id,
permission: Permissions.ADD_REACTION,
});
}
let isFailed = post.failed;
let isPending = post.id === post.pending_post_id;
@@ -49,20 +26,6 @@ function mapStateToProps(state, ownProps) {
isPending = false;
}
const isUserCanManageMembers = canManageChannelMembers(state);
const isEphemeralPost = isPostEphemeral(post);
let isPostAddChannelMember = false;
if (
(channel.type === General.PRIVATE_CHANNEL || channel.type === General.OPEN_CHANNEL) &&
isUserCanManageMembers &&
isEphemeralPost &&
post.props &&
post.props.add_channel_member
) {
isPostAddChannelMember = true;
}
return {
postProps: post.props || {},
fileIds: post.file_ids,
@@ -71,12 +34,10 @@ function mapStateToProps(state, ownProps) {
hasReactions: post.has_reactions,
isFailed,
isPending,
isPostAddChannelMember,
isPostEphemeral: isEphemeralPost,
isPostEphemeral: isPostEphemeral(post),
isSystemMessage: isSystemMessage(post),
message: post.message,
theme: getTheme(state),
canAddReaction,
};
}

View File

@@ -4,29 +4,24 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Dimensions,
Platform,
Text,
TouchableHighlight,
TouchableOpacity,
View,
} from 'react-native';
import {intlShape} from 'react-intl';
import Icon from 'react-native-vector-icons/Ionicons';
import LinearGradient from 'react-native-linear-gradient';
import FileAttachmentList from 'app/components/file_attachment_list';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import OptionsContext from 'app/components/options_context';
import PostAddChannelMember from 'app/components/post_add_channel_member';
import PostBodyAdditionalContent from 'app/components/post_body_additional_content';
import {emptyFunction} from 'app/utils/general';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from 'app/utils/markdown';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import Reactions from 'app/components/reactions';
export default class PostBody extends PureComponent {
@@ -35,19 +30,15 @@ export default class PostBody extends PureComponent {
flagPost: PropTypes.func.isRequired,
unflagPost: PropTypes.func.isRequired,
}).isRequired,
canAddReaction: PropTypes.bool,
canDelete: PropTypes.bool,
canEdit: PropTypes.bool,
channelIsReadOnly: PropTypes.bool.isRequired,
fileIds: PropTypes.array,
hasBeenDeleted: PropTypes.bool,
hasBeenEdited: PropTypes.bool,
hasReactions: PropTypes.bool,
highlight: PropTypes.bool,
isFailed: PropTypes.bool,
isFlagged: PropTypes.bool,
isPending: PropTypes.bool,
isPostAddChannelMember: PropTypes.bool,
isPostEphemeral: PropTypes.bool,
isReplyPost: PropTypes.bool,
isSearchResult: PropTypes.bool,
@@ -66,8 +57,6 @@ export default class PostBody extends PureComponent {
postId: PropTypes.string.isRequired,
postProps: PropTypes.object,
renderReplyBar: PropTypes.func,
showAddReaction: PropTypes.bool,
showLongPost: PropTypes.bool.isRequired,
theme: PropTypes.object,
toggleSelected: PropTypes.func,
};
@@ -89,15 +78,6 @@ export default class PostBody extends PureComponent {
intl: intlShape.isRequired,
};
state = {
isLongPost: false,
};
flagPost = () => {
const {actions, postId} = this.props;
actions.flagPost(postId);
};
handleHideUnderlay = () => {
this.props.toggleSelected(false);
};
@@ -112,112 +92,11 @@ export default class PostBody extends PureComponent {
}
};
getPostActions = () => {
const {formatMessage} = this.context.intl;
const {
canEdit,
canDelete,
canAddReaction,
channelIsReadOnly,
hasBeenDeleted,
isPending,
isFailed,
isFlagged,
isPostEphemeral,
isSystemMessage,
managedConfig,
onCopyText,
onPostDelete,
onPostEdit,
showAddReaction,
} = this.props;
const actions = [];
const isPendingOrFailedPost = isPending || isFailed;
// we should check for the user roles and permissions
if (!isPendingOrFailedPost && !isSystemMessage && !isPostEphemeral) {
if (showAddReaction && canAddReaction && !channelIsReadOnly) {
actions.push({
text: formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}),
onPress: this.props.onAddReaction,
});
}
if (managedConfig.copyAndPasteProtection !== 'true') {
actions.push({
text: formatMessage({id: 'mobile.post_info.copy_post', defaultMessage: 'Copy Post'}),
onPress: onCopyText,
copyPost: true,
});
}
if (!channelIsReadOnly) {
if (isFlagged) {
actions.push({
text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}),
onPress: this.unflagPost,
});
} else {
actions.push({
text: formatMessage({id: 'post_info.mobile.flag', defaultMessage: 'Flag'}),
onPress: this.flagPost,
});
}
}
if (canEdit) {
actions.push({text: formatMessage({id: 'post_info.edit', defaultMessage: 'Edit'}), onPress: onPostEdit});
}
if (canDelete && !hasBeenDeleted) {
actions.push({text: formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}), onPress: onPostDelete});
}
actions.push({
text: formatMessage({id: 'get_post_link_modal.title', defaultMessage: 'Copy Permalink'}),
onPress: this.props.onCopyPermalink,
});
}
return actions;
flagPost = () => {
const {actions, postId} = this.props;
actions.flagPost(postId);
};
measurePost = (event) => {
const {height} = event.nativeEvent.layout;
const {height: deviceHeight} = Dimensions.get('window');
const {showLongPost} = this.props;
if (!showLongPost && height >= 1000) {
this.setState({
isLongPost: true,
maxHeight: (deviceHeight * 0.6),
});
}
};
openLongPost = preventDoubleTap(() => {
const {managedConfig, navigator, onAddReaction, onPermalinkPress, postId} = this.props;
const options = {
screen: 'LongPost',
animationType: 'none',
backButtonTitle: '',
overrideBackPress: true,
navigatorStyle: {
navBarHidden: true,
screenBackgroundColor: changeOpacity('#000', 0.2),
modalPresentationStyle: 'overCurrentContext',
},
passProps: {
postId,
managedConfig,
onAddReaction,
onPermalinkPress,
},
};
navigator.showModal(options);
});
unflagPost = () => {
const {actions, postId} = this.props;
actions.unflagPost(postId);
@@ -236,14 +115,9 @@ export default class PostBody extends PureComponent {
navigator,
onPress,
postId,
showLongPost,
toggleSelected,
} = this.props;
if (showLongPost) {
return null;
}
let attachments;
if (fileIds.length > 0) {
attachments = (
@@ -262,120 +136,83 @@ export default class PostBody extends PureComponent {
return attachments;
}
renderPostAdditionalContent = (blockStyles, messageStyle, textStyles) => {
const {isReplyPost, message, navigator, onPermalinkPress, postId, postProps} = this.props;
return (
<PostBodyAdditionalContent
baseTextStyle={messageStyle}
blockStyles={blockStyles}
navigator={navigator}
message={message}
postId={postId}
postProps={postProps}
textStyles={textStyles}
onLongPress={this.showOptionsContext}
isReplyPost={isReplyPost}
onPermalinkPress={onPermalinkPress}
/>
);
};
renderReactions = () => {
const {hasReactions, isSearchResult, postId, onAddReaction, showLongPost} = this.props;
if (!hasReactions || isSearchResult || showLongPost) {
return null;
}
return (
<Reactions
postId={postId}
onAddReaction={onAddReaction}
/>
);
};
renderShowMoreOption = (style) => {
const {highlight, theme} = this.props;
const {isLongPost} = this.state;
if (!isLongPost) {
return null;
}
const gradientColors = [];
if (highlight) {
gradientColors.push(
changeOpacity(theme.mentionHighlightBg, 0),
changeOpacity(theme.mentionHighlightBg, 0.15),
changeOpacity(theme.mentionHighlightBg, 0.5),
);
} else {
gradientColors.push(
changeOpacity(theme.centerChannelBg, 0),
changeOpacity(theme.centerChannelBg, 0.75),
theme.centerChannelBg,
);
}
return (
<View>
<LinearGradient
colors={gradientColors}
locations={[0, 0.7, 1]}
style={style.showMoreGradient}
/>
<View style={style.showMoreContainer}>
<View style={style.showMoreDividerLeft}/>
<TouchableOpacity
onPress={this.openLongPost}
style={style.showMoreButtonContainer}
>
<View style={style.showMoreButton}>
<Text style={style.showMorePlusSign}>
{'+'}
</Text>
<FormattedText
id='mobile.post_body.show_more'
defaultMessage='Show More'
style={style.showMoreText}
/>
</View>
</TouchableOpacity>
<View style={style.showMoreDividerRight}/>
</View>
</View>
);
};
render() {
render() { // eslint-disable-line complexity
const {formatMessage} = this.context.intl;
const {
canDelete,
canEdit,
hasBeenDeleted,
hasBeenEdited,
hasReactions,
isFailed,
isFlagged,
isPending,
isPostAddChannelMember,
isPostEphemeral,
isReplyPost,
isSearchResult,
isSystemMessage,
managedConfig,
message,
navigator,
onFailedPostPress,
onPermalinkPress,
onPostDelete,
onPostEdit,
onPress,
postId,
postProps,
renderReplyBar,
theme,
toggleSelected,
} = this.props;
const {isLongPost, maxHeight} = this.state;
const actions = [];
const style = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
const messageStyle = isSystemMessage ? [style.message, style.systemMessage] : style.message;
const isPendingOrFailedPost = isPending || isFailed;
// we should check for the user roles and permissions
if (!isPendingOrFailedPost && !isSystemMessage && !isPostEphemeral) {
actions.push({
text: formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}),
onPress: this.props.onAddReaction,
});
if (managedConfig.copyAndPasteProtection !== 'true') {
actions.push({
text: formatMessage({id: 'mobile.post_info.copy_post', defaultMessage: 'Copy Post'}),
onPress: this.props.onCopyText,
copyPost: true,
});
}
if (isFlagged) {
actions.push({
text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}),
onPress: this.unflagPost,
});
} else {
actions.push({
text: formatMessage({id: 'post_info.mobile.flag', defaultMessage: 'Flag'}),
onPress: this.flagPost,
});
}
if (canEdit) {
actions.push({text: formatMessage({id: 'post_info.edit', defaultMessage: 'Edit'}), onPress: onPostEdit});
}
if (canDelete && !hasBeenDeleted) {
actions.push({text: formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}), onPress: onPostDelete});
}
actions.push({
text: formatMessage({id: 'get_post_link_modal.title', defaultMessage: 'Copy Permalink'}),
onPress: this.props.onCopyPermalink,
});
}
let body;
let messageComponent;
if (hasBeenDeleted) {
@@ -386,7 +223,7 @@ export default class PostBody extends PureComponent {
onShowUnderlay={this.handleShowUnderlay}
underlayColor='transparent'
>
<View style={style.row}>
<View style={{flexDirection: 'row'}}>
<FormattedText
style={messageStyle}
id='post_body.deleted'
@@ -396,30 +233,10 @@ export default class PostBody extends PureComponent {
</TouchableHighlight>
);
body = (<View>{messageComponent}</View>);
} else if (isPostAddChannelMember) {
messageComponent = (
<View style={style.row}>
<View style={style.flex}>
<PostAddChannelMember
navigator={navigator}
onLongPress={this.showOptionsContext}
onPermalinkPress={onPermalinkPress}
onPostPress={onPress}
textStyles={textStyles}
postId={postProps.add_channel_member.post_id}
userIds={postProps.add_channel_member.user_ids}
usernames={postProps.add_channel_member.usernames}
/>
</View>
</View>
);
} else if (message.length) {
messageComponent = (
<View style={style.row}>
<View
style={[style.flex, (isPendingOrFailedPost && style.pendingPost), (isLongPost && {maxHeight, overflow: 'hidden'})]}
removeClippedSubviews={isLongPost}
>
<View style={{flexDirection: 'row'}}>
<View style={[{flex: 1}, (isPendingOrFailedPost && style.pendingPost)]}>
<Markdown
baseTextStyle={messageStyle}
blockStyles={blockStyles}
@@ -440,19 +257,32 @@ export default class PostBody extends PureComponent {
if (!hasBeenDeleted) {
body = (
<OptionsContext
actions={this.getPostActions()}
actions={actions}
ref='options'
onPress={onPress}
toggleSelected={toggleSelected}
cancelText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})}
>
<View onLayout={this.measurePost}>
{messageComponent}
{this.renderShowMoreOption(style)}
</View>
{this.renderPostAdditionalContent(blockStyles, messageStyle, textStyles)}
{messageComponent}
<PostBodyAdditionalContent
baseTextStyle={messageStyle}
blockStyles={blockStyles}
navigator={navigator}
message={message}
postId={postId}
postProps={postProps}
textStyles={textStyles}
onLongPress={this.showOptionsContext}
isReplyPost={isReplyPost}
onPermalinkPress={onPermalinkPress}
/>
{this.renderFileAttachments()}
{this.renderReactions()}
{!isSearchResult && hasReactions &&
<Reactions
postId={postId}
onAddReaction={this.props.onAddReaction}
/>
}
</OptionsContext>
);
}
@@ -460,14 +290,14 @@ export default class PostBody extends PureComponent {
return (
<View style={style.messageContainerWithReplyBar}>
{renderReplyBar()}
<View style={[style.flex, style.row]}>
<View style={style.flex}>
<View style={{flex: 1, flexDirection: 'row'}}>
<View style={{flex: 1}}>
{body}
</View>
{isFailed &&
<TouchableOpacity
onPress={onFailedPostPress}
style={style.retry}
style={{justifyContent: 'center', marginLeft: 12}}
>
<Icon
name='ios-information-circle-outline'
@@ -484,16 +314,6 @@ export default class PostBody extends PureComponent {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
flex: {
flex: 1,
},
row: {
flexDirection: 'row',
},
retry: {
justifyContent: 'center',
marginLeft: 12,
},
message: {
color: theme.centerChannelColor,
fontSize: 15,
@@ -509,56 +329,5 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
systemMessage: {
opacity: 0.6,
},
showMoreGradient: {
flex: 1,
height: 50,
position: 'absolute',
top: -50,
width: '100%',
},
showMoreContainer: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
flexDirection: 'row',
position: 'relative',
top: -7.5,
},
showMoreDividerLeft: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
flex: 1,
height: 1,
marginRight: 10,
},
showMoreDividerRight: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
flex: 1,
height: 1,
marginLeft: 10,
},
showMoreButtonContainer: {
backgroundColor: theme.centerChannelBg,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 4,
borderWidth: 1,
height: 37,
paddingHorizontal: 10,
},
showMoreButton: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
},
showMorePlusSign: {
color: theme.linkColor,
fontSize: 16,
fontWeight: '600',
marginRight: 8,
},
showMoreText: {
color: theme.linkColor,
fontSize: 13,
fontWeight: '600',
},
};
});

View File

@@ -5,6 +5,7 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Image,
ImageBackground,
Linking,
Platform,
StyleSheet,
@@ -16,13 +17,10 @@ import youTubeVideoId from 'youtube-video-id';
import youtubePlayIcon from 'assets/images/icons/youtube-play-icon.png';
import MessageAttachments from 'app/components/message_attachments';
import PostAttachmentOpenGraph from 'app/components/post_attachment_opengraph';
import ProgressiveImage from 'app/components/progressive_image';
import MessageAttachments from 'app/components/message_attachments';
import CustomPropTypes from 'app/constants/custom_prop_types';
import {emptyFunction} from 'app/utils/general';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {isImageLink, isYoutubeLink} from 'app/utils/url';
const MAX_IMAGE_HEIGHT = 150;
@@ -58,16 +56,14 @@ export default class PostBodyAdditionalContent extends PureComponent {
this.state = {
linkLoadError: false,
linkLoaded: false,
width: 0,
height: 0,
};
this.mounted = false;
}
componentWillMount() {
componentDidMount() {
this.mounted = true;
this.load(this.props);
this.getImageSize();
}
componentWillUnmount() {
@@ -75,29 +71,16 @@ export default class PostBodyAdditionalContent extends PureComponent {
}
componentWillReceiveProps(nextProps) {
if (this.props.link !== nextProps.link) {
this.load(nextProps);
if (nextProps.message !== this.props.message) {
this.setState({
linkLoadError: false,
linkLoaded: false,
}, () => {
this.getImageSize();
});
}
}
load = (props) => {
const {link} = props;
if (link) {
let imageUrl;
if (isImageLink(link)) {
imageUrl = link;
} else if (isYoutubeLink(link)) {
const videoId = youTubeVideoId(link);
imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
ImageCacheManager.cache(null, `https://i.ytimg.com/vi/${videoId}/default.jpg`, () => true);
}
if (imageUrl) {
ImageCacheManager.cache(null, imageUrl, this.getImageSize);
}
}
};
calculateDimensions = (width, height) => {
const {deviceHeight, deviceWidth} = this.props;
let maxHeight = MAX_IMAGE_HEIGHT;
@@ -125,7 +108,7 @@ export default class PostBodyAdditionalContent extends PureComponent {
return null;
}
const {isReplyPost, link, navigator, openGraphData, showLinkPreviews, theme} = this.props;
const {isReplyPost, link, openGraphData, showLinkPreviews, theme} = this.props;
const attachments = this.getMessageAttachment();
if (attachments) {
return attachments;
@@ -136,7 +119,6 @@ export default class PostBodyAdditionalContent extends PureComponent {
<PostAttachmentOpenGraph
isReplyPost={isReplyPost}
link={link}
navigator={navigator}
openGraphData={openGraphData}
theme={theme}
/>
@@ -146,105 +128,30 @@ export default class PostBodyAdditionalContent extends PureComponent {
return null;
};
generateToggleableEmbed = (isImage, isYouTube) => {
getImageSize = () => {
const {link} = this.props;
const {width, height, uri} = this.state;
const imgHeight = height || MAX_IMAGE_HEIGHT;
const {linkLoaded} = this.state;
if (link) {
if (isYouTube) {
let imageUrl;
if (isImageLink(link)) {
imageUrl = link;
} else if (isYoutubeLink(link)) {
const videoId = youTubeVideoId(link);
const imgUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
const thumbUrl = `https://i.ytimg.com/vi/${videoId}/default.jpg`;
return (
<TouchableWithoutFeedback
style={[styles.imageContainer, {height: imgHeight}]}
{...this.responder}
onPress={this.playYouTubeVideo}
>
<ProgressiveImage
isBackgroundImage={true}
imageUri={imgUrl}
style={[styles.image, {width, height: imgHeight}]}
thumbnailUri={thumbUrl}
resizeMode='cover'
onError={this.handleLinkLoadError}
>
<TouchableWithoutFeedback onPress={this.playYouTubeVideo}>
<Image
source={youtubePlayIcon}
onPress={this.playYouTubeVideo}
/>
</TouchableWithoutFeedback>
</ProgressiveImage>
</TouchableWithoutFeedback>
);
imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
}
if (isImage) {
return (
<TouchableWithoutFeedback
onPress={this.handlePreviewImage}
style={[styles.imageContainer, {height: imgHeight}]}
{...this.responder}
>
<View ref='item'>
<ProgressiveImage
ref='image'
style={[styles.image, {width, height: imgHeight}]}
defaultSource={{uri}}
resizeMode='cover'
onError={this.handleLinkLoadError}
/>
</View>
</TouchableWithoutFeedback>
);
if (imageUrl && !linkLoaded) {
Image.getSize(imageUrl, (width, height) => {
if (!this.mounted) {
return;
}
const dimensions = this.calculateDimensions(width, height);
this.setState({...dimensions, linkLoaded: true});
}, () => null);
}
}
return null;
};
getImageSize = (path) => {
const {link} = this.props;
if (link && path) {
let prefix = '';
if (Platform.OS === 'android') {
prefix = 'file://';
}
const uri = `${prefix}${path}`;
Image.getSize(uri, (width, height) => {
if (!this.mounted) {
return;
}
if (!width && !height) {
this.setState({linkLoadError: true});
return;
}
const dimensions = this.calculateDimensions(width, height);
this.setState({...dimensions, linkLoaded: true, uri});
}, () => this.setState({linkLoadError: true}));
}
};
getItemMeasures = (index, cb) => {
const activeComponent = this.refs.item;
if (!activeComponent) {
cb(null);
return;
}
activeComponent.measure((rx, ry, width, height, x, y) => {
cb({
origin: {x, y, width, height},
});
});
};
getMessageAttachment = () => {
@@ -279,59 +186,53 @@ export default class PostBodyAdditionalContent extends PureComponent {
return null;
};
getPreviewProps = () => {
const previewComponent = this.refs.image;
return previewComponent ? {...previewComponent.props} : {};
};
generateToggleableEmbed = (isImage, isYouTube) => {
const {link} = this.props;
const {width, height} = this.state;
goToImagePreview = (passProps) => {
this.props.navigator.showModal({
screen: 'ImagePreview',
title: '',
animationType: 'none',
passProps,
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
modalPresentationStyle: 'overCurrentContext',
},
});
};
if (link) {
if (isYouTube) {
const videoId = youTubeVideoId(link);
const imgUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
handleLinkLoadError = () => {
this.setState({linkLoadError: true});
};
return (
<TouchableWithoutFeedback
style={styles.imageContainer}
{...this.responder}
onPress={this.playYouTubeVideo}
>
<ImageBackground
style={[styles.image, {width, height}]}
source={{uri: imgUrl}}
resizeMode={'cover'}
onError={this.handleLinkLoadError}
>
<TouchableWithoutFeedback onPress={this.playYouTubeVideo}>
<Image
source={youtubePlayIcon}
onPress={this.playYouTubeVideo}
/>
</TouchableWithoutFeedback>
</ImageBackground>
</TouchableWithoutFeedback>
);
}
handlePreviewImage = () => {
const component = this.refs.item;
if (!component) {
return;
if (isImage) {
return (
<View style={styles.imageContainer}>
<Image
style={[styles.image, {width, height}]}
source={{uri: link}}
resizeMode={'cover'}
onError={this.handleLinkLoadError}
/>
</View>
);
}
}
component.measure((rx, ry, width, height, x, y) => {
const {link} = this.props;
const {uri} = this.state;
const filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?'));
const files = [{
caption: filename,
source: {uri},
data: {
localPath: uri,
},
}];
this.goToImagePreview({
index: 0,
origin: {x, y, width, height},
target: {x: 0, y: 0, opacity: 1},
files,
getItemMeasures: this.getItemMeasures,
getPreviewProps: this.getPreviewProps,
});
});
return null;
};
playYouTubeVideo = () => {
@@ -355,9 +256,13 @@ export default class PostBodyAdditionalContent extends PureComponent {
}
};
handleLinkLoadError = () => {
this.setState({linkLoadError: true});
};
render() {
const {link, openGraphData, postProps} = this.props;
const {linkLoadError} = this.state;
const {linkLoaded, linkLoadError} = this.state;
const {attachments} = postProps;
if (!link && !attachments) {
@@ -370,12 +275,12 @@ export default class PostBodyAdditionalContent extends PureComponent {
if (((isImage && !isOpenGraph) || isYouTube) && !linkLoadError) {
const embed = this.generateToggleableEmbed(isImage, isYouTube);
if (embed) {
if (embed && (linkLoaded || isYouTube)) {
return embed;
}
}
return this.generateStaticEmbed(isYouTube, isImage && !linkLoadError);
return this.generateStaticEmbed(isYouTube, isImage);
}
}

View File

@@ -25,7 +25,7 @@ export default class PostHeader extends PureComponent {
commentCount: PropTypes.number,
commentedOnDisplayName: PropTypes.string,
createAt: PropTypes.number.isRequired,
displayName: PropTypes.string,
displayName: PropTypes.string.isRequired,
enablePostUsernameOverride: PropTypes.bool,
fromWebHook: PropTypes.bool,
isPendingOrFailedPost: PropTypes.bool,
@@ -39,7 +39,7 @@ export default class PostHeader extends PureComponent {
shouldRenderReplyButton: PropTypes.bool,
showFullDate: PropTypes.bool,
theme: PropTypes.object.isRequired,
username: PropTypes.string,
username: PropTypes.string.isRequired,
isFlagged: PropTypes.bool,
};
@@ -50,9 +50,7 @@ export default class PostHeader extends PureComponent {
};
handleUsernamePress = () => {
if (this.props.username) {
this.props.onUsernamePress(this.props.username);
}
this.props.onUsernamePress(this.props.username);
}
getDisplayName = (style) => {
@@ -266,7 +264,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
height: 35,
height: 30,
minWidth: 40,
paddingVertical: 10,
},

View File

@@ -10,6 +10,7 @@ import {
StyleSheet,
} from 'react-native';
import ChannelIntro from 'app/components/channel_intro';
import Post from 'app/components/post';
import {DATE_LINE, START_OF_NEW_MESSAGES} from 'app/selectors/post_list';
import mattermostManaged from 'app/mattermost_managed';
@@ -17,12 +18,13 @@ import {makeExtraData} from 'app/utils/list_view';
import {changeOpacity} from 'app/utils/theme';
import DateHeader from './date_header';
import LoadMorePosts from './load_more_posts';
import NewMessagesDivider from './new_messages_divider';
import withLayout from './with_layout';
const PostWithLayout = withLayout(Post);
const INITIAL_BATCH_TO_RENDER = 15;
const INITAL_BATCH_TO_RENDER = 15;
const NEW_MESSAGES_HEIGHT = 28;
const DATE_HEADER_HEIGHT = 28;
@@ -36,20 +38,19 @@ export default class PostList extends PureComponent {
channelId: PropTypes.string,
currentUserId: PropTypes.string,
deviceHeight: PropTypes.number.isRequired,
extraData: PropTypes.any,
highlightPostId: PropTypes.string,
indicateNewMessages: PropTypes.bool,
isSearchResult: PropTypes.bool,
lastViewedAt: PropTypes.number, // Used by container // eslint-disable-line no-unused-prop-types
loadMore: PropTypes.func,
measureCellLayout: PropTypes.bool,
navigator: PropTypes.object,
onEndReached: PropTypes.func,
onPermalinkPress: PropTypes.func,
onPostPress: PropTypes.func,
onRefresh: PropTypes.func,
postIds: PropTypes.array.isRequired,
renderFooter: PropTypes.func,
renderReplies: PropTypes.bool,
showLoadMore: PropTypes.bool,
shouldRenderReplyButton: PropTypes.bool,
theme: PropTypes.object.isRequired,
};
@@ -317,6 +318,28 @@ export default class PostList extends PureComponent {
);
};
renderFooter = () => {
if (!this.props.channelId) {
return null;
}
if (this.props.showLoadMore) {
return (
<LoadMorePosts
channelId={this.props.channelId}
theme={this.props.theme}
/>
);
}
return (
<ChannelIntro
channelId={this.props.channelId}
navigator={this.props.navigator}
/>
);
};
onLayout = (event) => {
const {height} = event.nativeEvent.layout;
this.setState({
@@ -328,8 +351,9 @@ export default class PostList extends PureComponent {
const {
channelId,
highlightPostId,
onEndReached,
loadMore,
postIds,
showLoadMore,
} = this.props;
const refreshControl = {
@@ -345,13 +369,13 @@ export default class PostList extends PureComponent {
onLayout={this.onLayout}
ref='list'
data={postIds}
extraData={this.makeExtraData(channelId, highlightPostId, this.props.extraData)}
initialNumToRender={10}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
extraData={this.makeExtraData(channelId, highlightPostId, showLoadMore)}
initialNumToRender={false}
maxToRenderPerBatch={INITAL_BATCH_TO_RENDER + 1}
inverted={true}
keyExtractor={this.keyExtractor}
ListFooterComponent={this.props.renderFooter}
onEndReached={onEndReached}
ListFooterComponent={this.renderFooter}
onEndReached={loadMore}
onEndReachedThreshold={Platform.OS === 'ios' ? 0 : 1}
removeClippedSubviews={Platform.OS === 'android'}
{...refreshControl}

View File

@@ -17,22 +17,25 @@ function withLayout(WrappedComponent) {
static defaultProps = {
onLayoutCalled: emptyFunction,
};
}
onLayout = (event) => {
const {height} = event.nativeEvent.layout;
const {shouldCallOnLayout} = this.props;
if (shouldCallOnLayout) {
this.props.onLayoutCalled(this.props.index, height);
}
this.props.onLayoutCalled(this.props.index, height);
};
render() {
return (
<View onLayout={this.onLayout}>
<WrappedComponent {...this.props}/>
</View>
);
const {index, onLayoutCalled, shouldCallOnLayout, ...otherProps} = this.props; //eslint-disable-line no-unused-vars
if (shouldCallOnLayout) {
return (
<View onLayout={this.onLayout}>
<WrappedComponent {...otherProps}/>
</View>
);
}
return <WrappedComponent {...otherProps}/>;
}
};
}

View File

@@ -9,7 +9,8 @@ import AppIcon from 'app/components/app_icon';
import ProfilePicture from 'app/components/profile_picture';
import {emptyFunction} from 'app/utils/general';
import webhookIcon from 'assets/images/icons/webhook.jpg';
import {ViewTypes} from 'app/constants';
const PROFILE_PICTURE_SIZE = 32;
export default class PostProfilePicture extends PureComponent {
static propTypes = {
@@ -42,8 +43,8 @@ export default class PostProfilePicture extends PureComponent {
<View>
<AppIcon
color={theme.centerChannelColor}
height={ViewTypes.PROFILE_PICTURE_SIZE}
width={ViewTypes.PROFILE_PICTURE_SIZE}
height={PROFILE_PICTURE_SIZE}
width={PROFILE_PICTURE_SIZE}
/>
</View>
);
@@ -57,9 +58,9 @@ export default class PostProfilePicture extends PureComponent {
<Image
source={icon}
style={{
height: ViewTypes.PROFILE_PICTURE_SIZE,
width: ViewTypes.PROFILE_PICTURE_SIZE,
borderRadius: ViewTypes.PROFILE_PICTURE_SIZE / 2,
height: PROFILE_PICTURE_SIZE,
width: PROFILE_PICTURE_SIZE,
borderRadius: PROFILE_PICTURE_SIZE / 2,
}}
/>
</View>
@@ -69,7 +70,7 @@ export default class PostProfilePicture extends PureComponent {
let component = (
<ProfilePicture
userId={userId}
size={ViewTypes.PROFILE_PICTURE_SIZE}
size={PROFILE_PICTURE_SIZE}
/>
);

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const SEPARATOR_HEIGHT = 3;
export default class PostSeparator extends PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
};
render() {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={[style.separatorContainer, style.postsSeparator]}>
<View style={style.separator}/>
</View>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
separatorContainer: {
justifyContent: 'center',
flex: 1,
height: SEPARATOR_HEIGHT,
},
postsSeparator: {
height: 15,
},
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: 1,
},
};
});

View File

@@ -5,16 +5,17 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {General} from 'mattermost-redux/constants';
import {createPost} from 'mattermost-redux/actions/posts';
import {getCurrentChannel, isCurrentChannelReadOnly} from 'mattermost-redux/selectors/entities/channels';
import {canUploadFilesOnMobile, getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {canUploadFilesOnMobile} from 'mattermost-redux/selectors/entities/general';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {executeCommand} from 'app/actions/views/command';
import {addReactionToLatestPost} from 'app/actions/views/emoji';
import {handlePostDraftChanged} from 'app/actions/views/channel';
import {handleClearFiles, handleClearFailedFiles, handleRemoveLastFile, initUploadFiles} from 'app/actions/views/file_upload';
import {handlePostDraftChanged, handlePostDraftSelectionChanged} from 'app/actions/views/channel';
import {handleClearFiles, handleClearFailedFiles, handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload';
import {handleCommentDraftChanged, handleCommentDraftSelectionChanged} from 'app/actions/views/thread';
import {userTyping} from 'app/actions/views/typing';
import {getCurrentChannelDraft, getThreadDraft} from 'app/selectors/views';
@@ -22,11 +23,8 @@ import {getChannelMembersForDm} from 'app/selectors/channel';
import PostTextbox from './post_textbox';
const MAX_MESSAGE_LENGTH = 4000;
function mapStateToProps(state, ownProps) {
const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state);
const config = getConfig(state);
const currentChannel = getCurrentChannel(state);
let deactivatedChannel = false;
@@ -41,11 +39,9 @@ function mapStateToProps(state, ownProps) {
channelId: ownProps.channelId || (currentChannel ? currentChannel.id : ''),
canUploadFiles: canUploadFilesOnMobile(state),
channelIsLoading: state.views.channel.loading,
channelIsReadOnly: isCurrentChannelReadOnly(state),
currentUserId: getCurrentUserId(state),
deactivatedChannel,
files: currentDraft.files,
maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH,
theme: getTheme(state),
uploadFileRequestStatus: state.requests.files.uploadFiles.status,
value: currentDraft.draft,
@@ -63,8 +59,9 @@ function mapDispatchToProps(dispatch) {
handleCommentDraftChanged,
handlePostDraftChanged,
handleRemoveLastFile,
initUploadFiles,
handleUploadFiles,
userTyping,
handlePostDraftSelectionChanged,
handleCommentDraftSelectionChanged,
}, dispatch),
};

View File

@@ -6,17 +6,21 @@ import PropTypes from 'prop-types';
import {Alert, BackHandler, Keyboard, Platform, Text, TextInput, TouchableOpacity, View} from 'react-native';
import {intlShape} from 'react-intl';
import {RequestStatus} from 'mattermost-redux/constants';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import AttachmentButton from 'app/components/attachment_button';
import Autocomplete from 'app/components/autocomplete';
import FileUploadPreview from 'app/components/file_upload_preview';
import PaperPlane from 'app/components/paper_plane';
import {INITIAL_HEIGHT, INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_CONTENT_HEIGHT, MAX_FILE_COUNT} from 'app/constants/post_textbox';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import Typing from './components/typing';
const INITIAL_HEIGHT = Platform.OS === 'ios' ? 34 : 36;
const MAX_CONTENT_HEIGHT = 100;
const MAX_MESSAGE_LENGTH = 4000;
const MAX_FILE_COUNT = 5;
const IS_REACTION_REGEX = /(^\+:([^:\s]*):)$/i;
export default class PostTextbox extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
@@ -28,18 +32,17 @@ export default class PostTextbox extends PureComponent {
handleClearFiles: PropTypes.func.isRequired,
handleClearFailedFiles: PropTypes.func.isRequired,
handleRemoveLastFile: PropTypes.func.isRequired,
initUploadFiles: PropTypes.func.isRequired,
handleUploadFiles: PropTypes.func.isRequired,
userTyping: PropTypes.func.isRequired,
handlePostDraftSelectionChanged: PropTypes.func.isRequired,
handleCommentDraftSelectionChanged: PropTypes.func.isRequired,
}).isRequired,
canUploadFiles: PropTypes.bool.isRequired,
channelId: PropTypes.string.isRequired,
channelIsLoading: PropTypes.bool.isRequired,
channelIsReadOnly: PropTypes.bool.isRequired,
currentUserId: PropTypes.string.isRequired,
deactivatedChannel: PropTypes.bool.isRequired,
files: PropTypes.array,
maxMessageLength: PropTypes.number.isRequired,
navigator: PropTypes.object,
rootId: PropTypes.string,
theme: PropTypes.object.isRequired,
@@ -62,7 +65,6 @@ export default class PostTextbox extends PureComponent {
this.state = {
contentHeight: INITIAL_HEIGHT,
cursorPosition: 0,
keyboardType: 'default',
value: props.value,
showFileMaxWarning: false,
@@ -70,8 +72,6 @@ export default class PostTextbox extends PureComponent {
}
componentDidMount() {
const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
EventEmitter.on(event, this.handleInsertTextToDraft);
if (Platform.OS === 'android') {
Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard);
BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack);
@@ -85,8 +85,6 @@ export default class PostTextbox extends PureComponent {
}
componentWillUnmount() {
const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
EventEmitter.off(event, this.handleInsertTextToDraft);
if (Platform.OS === 'android') {
Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard);
BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack);
@@ -104,7 +102,7 @@ export default class PostTextbox extends PureComponent {
};
canSend = () => {
const {files, maxMessageLength, uploadFileRequestStatus} = this.props;
const {files, uploadFileRequestStatus} = this.props;
const {value} = this.state;
const valueLength = value.trim().length;
@@ -117,10 +115,10 @@ export default class PostTextbox extends PureComponent {
});
const loadingComplete = filesLoading.length === 0;
return valueLength <= maxMessageLength && uploadFileRequestStatus !== RequestStatus.STARTED && loadingComplete;
return valueLength <= MAX_MESSAGE_LENGTH && uploadFileRequestStatus !== RequestStatus.STARTED && loadingComplete;
}
return valueLength > 0 && valueLength <= maxMessageLength;
return valueLength > 0 && valueLength <= MAX_MESSAGE_LENGTH;
};
changeDraft = (text) => {
@@ -139,10 +137,9 @@ export default class PostTextbox extends PureComponent {
checkMessageLength = (value) => {
const {intl} = this.context;
const {maxMessageLength} = this.props;
const valueLength = value.trim().length;
if (valueLength > maxMessageLength) {
if (valueLength > MAX_MESSAGE_LENGTH) {
Alert.alert(
intl.formatMessage({
id: 'mobile.message_length.title',
@@ -152,7 +149,7 @@ export default class PostTextbox extends PureComponent {
id: 'mobile.message_length.message',
defaultMessage: 'Your current message is too long. Current character count: {max}/{count}',
}, {
max: maxMessageLength,
max: MAX_MESSAGE_LENGTH,
count: valueLength,
})
);
@@ -173,16 +170,16 @@ export default class PostTextbox extends PureComponent {
};
handleContentSizeChange = (event) => {
if (Platform.OS === 'android') {
let contentHeight = event.nativeEvent.contentSize.height;
if (contentHeight < INITIAL_HEIGHT) {
contentHeight = INITIAL_HEIGHT;
}
this.setState({
contentHeight,
});
let contentHeight = event.nativeEvent.contentSize.height;
if (contentHeight < INITIAL_HEIGHT) {
contentHeight = INITIAL_HEIGHT;
} else if (Platform.OS === 'ios') {
contentHeight += 5;
}
this.setState({
contentHeight,
});
};
handleEndEditing = (e) => {
@@ -193,9 +190,13 @@ export default class PostTextbox extends PureComponent {
handlePostDraftSelectionChanged = (event) => {
const cursorPosition = event.nativeEvent.selection.end;
this.setState({
cursorPosition,
});
if (this.props.rootId) {
this.props.actions.handleCommentDraftSelectionChanged(this.props.rootId, cursorPosition);
} else {
this.props.actions.handlePostDraftSelectionChanged(this.props.channelId, cursorPosition);
}
this.autocomplete.getWrappedInstance().handleSelectionChange(event);
};
handleSendMessage = () => {
@@ -242,23 +243,6 @@ export default class PostTextbox extends PureComponent {
}
};
handleInsertTextToDraft = (text) => {
const {cursorPosition, value} = this.state;
let completed;
if (value.length === 0) {
completed = text;
} else {
const firstPart = value.substring(0, cursorPosition);
const secondPart = value.substring(cursorPosition);
completed = `${firstPart}${text}${secondPart}`;
}
this.setState({
value: completed,
});
}
handleTextChange = (value) => {
const {
actions,
@@ -275,7 +259,7 @@ export default class PostTextbox extends PureComponent {
};
handleUploadFiles = (images) => {
this.props.actions.initUploadFiles(images, this.props.rootId);
this.props.actions.handleUploadFiles(images, this.props.rootId);
};
renderSendButton = () => {
@@ -412,7 +396,6 @@ export default class PostTextbox extends PureComponent {
canUploadFiles,
channelId,
channelIsLoading,
channelIsReadOnly,
deactivatedChannel,
files,
navigator,
@@ -432,15 +415,13 @@ export default class PostTextbox extends PureComponent {
);
}
const {contentHeight, cursorPosition, showFileMaxWarning, value} = this.state;
const {showFileMaxWarning} = this.state;
const textInputHeight = Math.min(contentHeight, MAX_CONTENT_HEIGHT);
const textValue = channelIsLoading ? '' : value;
const textInputHeight = Math.min(this.state.contentHeight, MAX_CONTENT_HEIGHT);
const textValue = channelIsLoading ? '' : this.state.value;
let placeholder;
if (channelIsReadOnly) {
placeholder = {id: 'mobile.create_post.read_only', defaultMessage: 'This channel is read-only.'};
} else if (rootId) {
if (rootId) {
placeholder = {id: 'create_comment.addComment', defaultMessage: 'Add a comment...'};
} else {
placeholder = {id: 'create_post.write', defaultMessage: 'Write a message...'};
@@ -476,14 +457,13 @@ export default class PostTextbox extends PureComponent {
/>
<Autocomplete
ref={this.attachAutocomplete}
cursorPosition={cursorPosition}
onChangeText={this.handleTextChange}
value={this.state.value}
rootId={rootId}
/>
<View style={style.inputWrapper}>
{!channelIsReadOnly && attachmentButton}
<View style={[inputContainerStyle, (channelIsReadOnly && {marginLeft: 10})]}>
{attachmentButton}
<View style={inputContainerStyle}>
<TextInput
ref='input'
value={textValue}
@@ -495,12 +475,11 @@ export default class PostTextbox extends PureComponent {
numberOfLines={5}
blurOnSubmit={false}
underlineColorAndroid='transparent'
style={[style.input, Platform.OS === 'android' ? {height: textInputHeight} : {maxHeight: MAX_CONTENT_HEIGHT}]}
style={[style.input, {height: textInputHeight}]}
onContentSizeChange={this.handleContentSizeChange}
keyboardType={this.state.keyboardType}
onEndEditing={this.handleEndEditing}
disableFullscreenUI={true}
editable={!channelIsReadOnly}
/>
{this.renderSendButton()}
</View>

View File

@@ -6,15 +6,13 @@ import PropTypes from 'prop-types';
import {Image, Platform, View} from 'react-native';
import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome';
import {Client4} from 'mattermost-redux/client';
import UserStatus from 'app/components/user_status';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {emptyFunction} from 'app/utils/general';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import placeholder from 'assets/images/profile.jpg';
import {Client4} from 'mattermost-redux/client';
const STATUS_BUFFER = Platform.select({
ios: 3,
android: 2,
@@ -23,6 +21,7 @@ const STATUS_BUFFER = Platform.select({
export default class ProfilePicture extends PureComponent {
static propTypes = {
size: PropTypes.number,
statusBorderWidth: PropTypes.number,
statusSize: PropTypes.number,
user: PropTypes.object,
showStatus: PropTypes.bool,
@@ -38,69 +37,30 @@ export default class ProfilePicture extends PureComponent {
static defaultProps = {
showStatus: true,
size: 128,
statusBorderWidth: 2,
statusSize: 14,
edit: false,
};
state = {
pictureUrl: null,
};
componentWillMount() {
const {edit, imageUri, user} = this.props;
if (edit && imageUri) {
this.setImageURL(imageUri);
} else if (user) {
ImageCacheManager.cache('', Client4.getProfilePictureUrl(user.id, user.last_picture_update), this.setImageURL);
}
}
componentDidMount() {
if (!this.props.status && this.props.user) {
this.props.actions.getStatusForId(this.props.user.id);
}
this.mounted = true;
}
componentWillReceiveProps(nextProps) {
if (this.mounted) {
const url = this.props.user ? Client4.getProfilePictureUrl(this.props.user.id, this.props.user.last_picture_update) : null;
const nextUrl = nextProps.user ? Client4.getProfilePictureUrl(nextProps.user.id, nextProps.user.last_picture_update) : null;
if (url !== nextUrl) {
this.setState({
pictureUrl: null,
});
if (nextUrl) {
// empty function is so that promise unhandled is not triggered in dev mode
ImageCacheManager.cache('', nextUrl, this.setImageURL).then(emptyFunction).catch(emptyFunction);
}
}
if (nextProps.edit && nextProps.imageUri !== this.props.imageUri) {
this.setImageURL(nextProps.imageUri);
}
}
}
componentWillUnmount() {
this.mounted = false;
}
setImageURL = (pictureUrl) => {
if (this.mounted) {
this.setState({pictureUrl});
}
};
render() {
const {edit, showStatus, theme} = this.props;
const {pictureUrl} = this.state;
const {edit, imageUri, showStatus, theme} = this.props;
const style = getStyleSheet(theme);
let pictureUrl;
if (this.props.user) {
pictureUrl = Client4.getProfilePictureUrl(this.props.user.id, this.props.user.last_picture_update);
}
if (edit && imageUri) {
pictureUrl = imageUri;
}
let statusIcon;
let statusStyle;
if (edit) {
@@ -126,25 +86,12 @@ export default class ProfilePicture extends PureComponent {
);
}
let source = null;
if (pictureUrl) {
let prefix = '';
if (Platform.OS === 'android' && !pictureUrl.startsWith('content://') &&
!pictureUrl.startsWith('http://') && !pictureUrl.startsWith('https://')) {
prefix = 'file://';
}
source = {
uri: `${prefix}${pictureUrl}`,
};
}
return (
<View style={{width: this.props.size + STATUS_BUFFER, height: this.props.size + STATUS_BUFFER}}>
<Image
key={pictureUrl}
style={{width: this.props.size, height: this.props.size, borderRadius: this.props.size / 2}}
source={source}
source={{uri: pictureUrl}}
defaultSource={placeholder}
/>
{(showStatus || edit) &&

View File

@@ -1,16 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import ProgressiveImage from './progressive_image';
function mapStateToProps(state) {
return {
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(ProgressiveImage);

View File

@@ -1,195 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Animated, Image, ImageBackground, Platform, View, StyleSheet} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {changeOpacity} from 'app/utils/theme';
const AnimatedImageBackground = Animated.createAnimatedComponent(ImageBackground);
export default class ProgressiveImage extends PureComponent {
static propTypes = {
isBackgroundImage: PropTypes.bool,
children: CustomPropTypes.Children,
defaultSource: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), // this should be provided by the component
filename: PropTypes.string,
imageUri: PropTypes.string,
style: CustomPropTypes.Style,
theme: PropTypes.object.isRequired,
thumbnailUri: PropTypes.string,
tintDefaultSource: PropTypes.bool,
};
constructor(props) {
super(props);
this.subscribedToCache = true;
this.state = {
intensity: null,
thumb: null,
uri: null,
};
}
componentWillMount() {
const intensity = new Animated.Value(80);
this.setState({intensity});
this.load(this.props);
}
componentWillReceiveProps(props) {
this.load(props);
}
componentDidUpdate(prevProps, prevState) {
const {intensity, thumb, uri} = this.state;
if (uri && thumb && uri !== thumb && prevState.uri !== uri) {
Animated.timing(intensity, {
duration: 300,
toValue: 0,
useNativeDriver: Platform.OS === 'android',
}).start();
}
}
componentWillUnmount() {
this.subscribedToCache = false;
}
load = (props) => {
const {filename, imageUri, style, thumbnailUri} = props;
this.style = [
StyleSheet.absoluteFill,
...style,
];
if (thumbnailUri) {
ImageCacheManager.cache(filename, thumbnailUri, this.setThumbnail);
} else if (imageUri) {
ImageCacheManager.cache(filename, imageUri, this.setImage);
}
};
setImage = (uri) => {
if (this.subscribedToCache) {
let path = uri;
if (Platform.OS === 'android') {
path = `file://${uri}`;
}
this.setState({uri: path});
}
};
setThumbnail = (thumb) => {
if (this.subscribedToCache) {
const {filename, imageUri} = this.props;
let path = thumb;
if (Platform.OS === 'android') {
path = `file://${thumb}`;
}
this.setState({thumb: path}, () => {
setTimeout(() => {
ImageCacheManager.cache(filename, imageUri, this.setImage);
}, 300);
});
}
};
render() {
const {style, defaultSource, isBackgroundImage, theme, tintDefaultSource, ...otherProps} = this.props;
const {style: computedStyle} = this;
const {uri, intensity, thumb} = this.state;
const hasDefaultSource = Boolean(defaultSource);
const hasPreview = Boolean(thumb);
const hasURI = Boolean(uri);
const isImageReady = uri && uri !== thumb;
const opacity = intensity.interpolate({
inputRange: [50, 100],
outputRange: [0.5, 1],
});
let DefaultComponent;
let ImageComponent;
if (isBackgroundImage) {
DefaultComponent = ImageBackground;
ImageComponent = AnimatedImageBackground;
} else {
DefaultComponent = Image;
ImageComponent = Animated.Image;
}
let defaultImage;
if (hasDefaultSource && tintDefaultSource) {
defaultImage = (
<View style={styles.defaultImageContainer}>
<DefaultComponent
{...otherProps}
source={defaultSource}
style={{flex: 1, tintColor: changeOpacity(theme.centerChannelColor, 0.2)}}
resizeMode='center'
>
{this.props.children}
</DefaultComponent>
</View>
);
} else {
defaultImage = (
<DefaultComponent
{...otherProps}
source={defaultSource}
style={computedStyle}
>
{this.props.children}
</DefaultComponent>
);
}
return (
<View {...{style}}>
{(hasDefaultSource && !hasPreview && !hasURI) && defaultImage}
{hasPreview && !isImageReady &&
<ImageComponent
{...otherProps}
source={{uri: thumb}}
style={computedStyle}
blurRadius={5}
>
{this.props.children}
</ImageComponent>
}
{isImageReady &&
<ImageComponent
{...otherProps}
source={{uri}}
style={computedStyle}
>
{this.props.children}
</ImageComponent>
}
{hasPreview &&
<Animated.View style={[computedStyle, {backgroundColor: theme.centerChannelBg, opacity}]}/>
}
</View>
);
}
}
const styles = StyleSheet.create({
defaultImageContainer: {
flex: 1,
position: 'absolute',
height: 80,
width: 80,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -5,13 +5,9 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getReactionsForPost, removeReaction} from 'mattermost-redux/actions/posts';
import {makeGetReactionsForPost, getPost} from 'mattermost-redux/selectors/entities/posts';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {hasNewPermissions} from 'mattermost-redux/selectors/entities/general';
import Permissions from 'mattermost-redux/constants/permissions';
import {makeGetReactionsForPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {addReaction} from 'app/actions/views/emoji';
@@ -20,25 +16,6 @@ import Reactions from './reactions';
function makeMapStateToProps() {
const getReactionsForPostSelector = makeGetReactionsForPost();
return function mapStateToProps(state, ownProps) {
const post = getPost(state, ownProps.postId);
const channel = getChannel(state, post.channel_id) || {};
const teamId = channel.team_id;
let canAddReaction = true;
let canRemoveReaction = true;
if (hasNewPermissions(state)) {
canAddReaction = haveIChannelPermission(state, {
team: teamId,
channel: post.channel_id,
permission: Permissions.ADD_REACTION,
});
canRemoveReaction = haveIChannelPermission(state, {
team: teamId,
channel: post.channel_id,
permission: Permissions.REMOVE_REACTION,
});
}
const currentUserId = getCurrentUserId(state);
const reactionsForPost = getReactionsForPostSelector(state, ownProps.postId);
@@ -61,8 +38,6 @@ function makeMapStateToProps() {
highlightedReactions,
reactions: reactionsByName,
theme: getTheme(state),
canAddReaction,
canRemoveReaction,
};
};
}

View File

@@ -36,7 +36,7 @@ export default class Reaction extends PureComponent {
>
<Emoji
emojiName={emojiName}
size={20}
size={15}
padding={5}
/>
<Text style={styles.count}>{count}</Text>
@@ -60,10 +60,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
borderColor: changeOpacity(theme.linkColor, 0.4),
borderWidth: 1,
flexDirection: 'row',
height: 30,
marginRight: 6,
marginBottom: 5,
marginTop: 10,
marginVertical: 5,
paddingVertical: 2,
paddingHorizontal: 6,
},

View File

@@ -4,14 +4,13 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import addReactionIcon from 'assets/images/icons/reaction.png';
import Reaction from './reaction';
@@ -24,18 +23,11 @@ export default class Reactions extends PureComponent {
}).isRequired,
highlightedReactions: PropTypes.array.isRequired,
onAddReaction: PropTypes.func.isRequired,
position: PropTypes.oneOf(['right', 'left']),
postId: PropTypes.string.isRequired,
reactions: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
canAddReaction: PropTypes.bool,
canRemoveReaction: PropTypes.bool.isRequired,
}
static defaultProps = {
position: 'right',
};
componentDidMount() {
const {actions, postId} = this.props;
actions.getReactionsForPost(postId);
@@ -43,12 +35,12 @@ export default class Reactions extends PureComponent {
handleReactionPress = (emoji, remove) => {
const {actions, postId} = this.props;
if (remove && this.props.canRemoveReaction) {
if (remove) {
actions.removeReaction(postId, emoji);
} else if (!remove && this.props.canAddReaction) {
} else {
actions.addReaction(postId, emoji);
}
};
}
renderReactions = () => {
const {highlightedReactions, reactions, theme} = this.props;
@@ -65,10 +57,10 @@ export default class Reactions extends PureComponent {
/>
);
});
};
}
render() {
const {position, reactions} = this.props;
const {reactions} = this.props;
const styles = getStyleSheet(this.props.theme);
if (!reactions.size) {
@@ -77,71 +69,45 @@ export default class Reactions extends PureComponent {
const addMoreReactions = (
<TouchableOpacity
key='addReaction'
onPress={this.props.onAddReaction}
style={[styles.reaction]}
>
<Image
source={addReactionIcon}
style={styles.addReaction}
/>
<Text style={styles.more}>{'+'}</Text>
</TouchableOpacity>
);
const reactionElements = [];
switch (position) {
case 'right':
reactionElements.push(
this.renderReactions(),
addMoreReactions
);
break;
case 'left':
reactionElements.push(
addMoreReactions,
this.renderReactions()
);
break;
}
return (
<View style={styles.container}>
<ScrollView
alwaysBounceHorizontal={false}
horizontal={true}
overScrollMode='never'
>
{reactionElements}
</ScrollView>
<View style={style.reactions}>
{this.renderReactions()}
{addMoreReactions}
</View>
);
}
}
const style = StyleSheet.create({
reactions: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-start',
},
});
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
},
addReaction: {
tintColor: changeOpacity(theme.centerChannelColor, 0.5),
width: 23,
height: 20,
more: {
color: theme.linkColor,
},
reaction: {
alignItems: 'center',
justifyContent: 'center',
borderRadius: 2,
borderColor: changeOpacity(theme.centerChannelColor, 0.3),
borderColor: changeOpacity(theme.linkColor, 0.4),
borderWidth: 1,
flexDirection: 'row',
height: 30,
marginRight: 6,
marginBottom: 5,
marginTop: 10,
marginVertical: 5,
paddingVertical: 2,
paddingHorizontal: 6,
width: 40,
},
};
});

View File

@@ -180,60 +180,13 @@ export default class SettingsDrawer extends PureComponent {
});
goToEditProfile = preventDoubleTap(() => {
const {currentUser} = this.props;
const {currentUser, navigator, theme} = this.props;
const {formatMessage} = this.context.intl;
this.closeSettingsDrawer();
this.openModal(
'EditProfile',
formatMessage({id: 'mobile.routes.edit_profile', defaultMessage: 'Edit Profile'}),
{currentUser}
);
});
goToFlagged = preventDoubleTap(() => {
const {formatMessage} = this.context.intl;
this.closeSettingsDrawer();
this.openModal(
'FlaggedPosts',
formatMessage({id: 'search_header.title3', defaultMessage: 'Flagged Posts'}),
);
});
goToMentions = preventDoubleTap(() => {
const {intl} = this.context;
this.closeSettingsDrawer();
this.openModal(
'RecentMentions',
intl.formatMessage({id: 'search_header.title2', defaultMessage: 'Recent Mentions'}),
);
});
goToSettings = preventDoubleTap(() => {
const {intl} = this.context;
this.closeSettingsDrawer();
this.openModal(
'Settings',
intl.formatMessage({id: 'mobile.routes.settings', defaultMessage: 'Settings'}),
);
});
logout = preventDoubleTap(() => {
const {logout} = this.props.actions;
this.closeSettingsDrawer();
InteractionManager.runAfterInteractions(logout);
});
openModal = (screen, title, passProps) => {
const {navigator, theme} = this.props;
this.closeSettingsDrawer();
navigator.showModal({
screen,
title,
screen: 'EditProfile',
title: formatMessage({id: 'mobile.routes.edit_profile', defaultMessage: 'Edit Profile'}),
animationType: 'slide-up',
animated: true,
backButtonTitle: '',
@@ -249,9 +202,43 @@ export default class SettingsDrawer extends PureComponent {
icon: this.closeButton,
}],
},
passProps,
passProps: {
currentUser,
},
});
};
});
goToSettings = preventDoubleTap(() => {
const {intl} = this.context;
const {navigator, theme} = this.props;
this.closeSettingsDrawer();
navigator.showModal({
screen: 'Settings',
title: intl.formatMessage({id: 'mobile.routes.settings', defaultMessage: 'Settings'}),
animationType: 'slide-up',
animated: true,
backButtonTitle: '',
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg,
},
navigatorButtons: {
leftButtons: [{
id: 'close-settings',
icon: this.closeButton,
}],
},
});
});
logout = preventDoubleTap(() => {
const {logout} = this.props.actions;
this.closeSettingsDrawer();
InteractionManager.runAfterInteractions(logout);
});
renderUserStatusIcon = (userId) => {
return (
@@ -295,34 +282,10 @@ export default class SettingsDrawer extends PureComponent {
<DrawerItem
labelComponent={this.renderUserStatusLabel(currentUser.id)}
leftComponent={this.renderUserStatusIcon(currentUser.id)}
separator={false}
separator={true}
onPress={this.handleSetStatus}
theme={theme}
/>
</View>
<View style={style.separator}/>
<View style={style.block}>
<DrawerItem
defaultMessage='Recent Mentions'
i18nId='search_header.title2'
iconName='ios-at-outline'
iconType='ion'
onPress={this.goToMentions}
separator={true}
theme={theme}
/>
<DrawerItem
defaultMessage='Flagged Posts'
i18nId='search_header.title3'
iconName='ios-flag-outline'
iconType='ion'
onPress={this.goToFlagged}
separator={false}
theme={theme}
/>
</View>
<View style={style.separator}/>
<View style={style.block}>
<DrawerItem
defaultMessage='Settings'
i18nId='mobile.routes.settings'

View File

@@ -49,7 +49,7 @@ export default class TeamIcon extends React.PureComponent {
teamIconContent = (
<Image
style={[styles.image, styleImage]}
source={{uri: teamIconUrl, headers: {Authorization: `Bearer ${Client4.getToken()}`}}}
source={{uri: teamIconUrl}}
onError={() => this.setState({imageError: true})}
/>
);
@@ -94,4 +94,4 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
right: 0,
},
};
});
});

View File

@@ -10,6 +10,7 @@ import {
AppState,
Image,
TouchableOpacity,
TouchableWithoutFeedback,
StyleSheet,
Text,
View,
@@ -52,7 +53,6 @@ export default class VideoControls extends PureComponent {
this.state = {
opacity: new Animated.Value(1),
isVisible: true,
isSeeking: false,
};
}
@@ -79,12 +79,7 @@ export default class VideoControls extends PureComponent {
fadeInControls = (loop = true) => {
this.setState({isVisible: true});
Animated.timing(this.state.opacity, {
toValue: 1,
duration: 250,
delay: 0,
useNativeDriver: true,
}).start(() => {
Animated.timing(this.state.opacity, {toValue: 1, duration: 250, delay: 0}).start(() => {
if (loop) {
this.fadeOutControls(2000);
}
@@ -92,12 +87,7 @@ export default class VideoControls extends PureComponent {
};
fadeOutControls = (delay = 0) => {
Animated.timing(this.state.opacity, {
toValue: 0,
duration: 250,
delay,
useNativeDriver: true,
}).start((result) => {
Animated.timing(this.state.opacity, {toValue: 0, duration: 250, delay}).start((result) => {
if (result.finished) {
this.setState({isVisible: false});
}
@@ -146,6 +136,10 @@ export default class VideoControls extends PureComponent {
};
renderControls() {
if (!this.state.isVisible) {
return null;
}
return (
<View style={styles.container}>
<View style={styles.controlsRow}/>
@@ -166,9 +160,8 @@ export default class VideoControls extends PureComponent {
</View>
<Slider
style={styles.progressSlider}
onSlidingComplete={this.seekVideoEnd}
onValueChange={this.seekVideo}
onSlidingStart={this.seekVideoStart}
onSlidingComplete={this.seekVideo}
onSlidingStart={this.seekStart}
maximumValue={Math.floor(this.props.duration)}
value={Math.floor(this.props.progress)}
trackStyle={styles.track}
@@ -187,30 +180,19 @@ export default class VideoControls extends PureComponent {
);
}
seekVideo = (value) => {
this.setState({isSeeking: true});
this.props.onSeek(value);
seekStart = () => {
if (this.props.onSeeking) {
this.props.onSeeking(false);
}
};
seekVideoEnd = (value) => {
this.setState({isSeeking: false});
if (this.props.playerState === PLAYER_STATE.PLAYING) {
this.toggleControls();
}
seekVideo = (value) => {
this.props.onSeek(value);
if (this.props.onSeeking) {
this.props.onSeeking(true);
}
};
seekVideoStart = () => {
this.setState({isSeeking: true});
this.cancelAnimation();
if (this.props.onSeeking) {
this.props.onSeeking(false);
}
};
setPlayerControls = (playerState) => {
const icon = this.getPlayerStateIcon(playerState);
const pressAction = playerState === PLAYER_STATE.ENDED ? this.onReplay : this.onPause;
@@ -249,14 +231,12 @@ export default class VideoControls extends PureComponent {
};
render() {
if (!this.state.isVisible) {
return null;
}
return (
<Animated.View style={[styles.container, {opacity: this.state.opacity}]}>
{this.renderControls()}
</Animated.View>
<TouchableWithoutFeedback onPress={this.toggleControls}>
<Animated.View style={[styles.container, {opacity: this.state.opacity}]}>
{this.renderControls()}
</Animated.View>
</TouchableWithoutFeedback>
);
}
}
@@ -269,7 +249,7 @@ const styles = StyleSheet.create({
paddingVertical: 13,
flexDirection: 'column',
alignItems: 'center',
backgroundColor: 'transparent',
backgroundColor: 'rgba(45, 59, 62, 0.4)',
justifyContent: 'space-between',
top: 0,
left: 0,

View File

@@ -15,6 +15,5 @@ const deviceTypes = keyMirror({
export default {
...deviceTypes,
DOCUMENTS_PATH: `${RNFetchBlob.fs.dirs.CacheDir}/Documents`,
IMAGES_PATH: `${RNFetchBlob.fs.dirs.CacheDir}/Images`,
VIDEOS_PATH: `${RNFetchBlob.fs.dirs.CacheDir}/Videos`,
};

View File

@@ -1,11 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Platform} from 'react-native';
export const INITIAL_HEIGHT = Platform.OS === 'ios' ? 34 : 36;
export const MAX_CONTENT_HEIGHT = 100;
export const MAX_FILE_COUNT = 5;
export const IS_REACTION_REGEX = /(^\+:([^:\s]*):)$/i;
export const INSERT_TO_DRAFT = 'insert_to_draft';
export const INSERT_TO_COMMENT = 'insert_to_comment';

View File

@@ -38,13 +38,14 @@ const ViewTypes = keyMirror({
REMOVE_FILE_FROM_POST_DRAFT: null,
REMOVE_LAST_FILE_FROM_POST_DRAFT: null,
ADD_FILE_TO_FETCH_CACHE: null,
SET_CHANNEL_LOADER: null,
SET_CHANNEL_REFRESHING: null,
SET_CHANNEL_RETRY_FAILED: null,
SET_CHANNEL_DISPLAY_NAME: null,
SET_LAST_CHANNEL_FOR_TEAM: null,
REMOVE_LAST_CHANNEL_FOR_TEAM: null,
GITLAB: null,
SAML: null,
@@ -80,5 +81,4 @@ export default {
IOS_TOP_PORTRAIT: 64,
IOSX_TOP_PORTRAIT: 88,
STATUS_BAR_HEIGHT: 20,
PROFILE_PICTURE_SIZE: 32,
};

View File

@@ -40,7 +40,6 @@ const state = {
posts: {
posts: {},
postsInChannel: {},
postsInThread: {},
selectedPostId: '',
currentFocusedPostId: '',
},
@@ -263,6 +262,7 @@ const state = {
channel: {
drafts: {},
},
fetchCache: {},
i18n: {
locale: '',
},

View File

@@ -26,6 +26,15 @@ function handlePostDraftChanged(state, action) {
};
}
function handlePostDraftSelectionChanged(state, action) {
return {
...state,
[action.channelId]: Object.assign({}, state[action.channelId], {
cursorPosition: action.cursorPosition,
}),
};
}
function handleSetPostDraft(state, action) {
return {
...state,
@@ -105,7 +114,6 @@ function handleReceivedUploadFiles(state, action) {
return {
...file,
localPath: tempFile.localPath,
loading: false,
};
}
@@ -196,6 +204,8 @@ function drafts(state = {}, action) { // eslint-disable-line complexity
switch (action.type) {
case ViewTypes.POST_DRAFT_CHANGED:
return handlePostDraftChanged(state, action);
case ViewTypes.POST_DRAFT_SELECTION_CHANGED:
return handlePostDraftSelectionChanged(state, action);
case ViewTypes.SET_POST_DRAFT:
return handleSetPostDraft(state, action);
case ChannelTypes.SELECT_CHANNEL:

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
export default function fetchCache(state = {}, action) {
switch (action.type) {
case ViewTypes.ADD_FILE_TO_FETCH_CACHE:
return {
...state,
[action.url]: true,
};
default:
return state;
}
}

View File

@@ -7,6 +7,7 @@ import announcement from './announcement';
import channel from './channel';
import clientUpgrade from './client_upgrade';
import extension from './extension';
import fetchCache from './fetch_cache';
import i18n from './i18n';
import login from './login';
import recentEmojis from './recent_emojis';
@@ -22,6 +23,7 @@ export default combineReducers({
channel,
clientUpgrade,
extension,
fetchCache,
i18n,
login,
recentEmojis,

View File

@@ -44,29 +44,6 @@ function lastChannelForTeam(state = {}, action) {
[action.teamId]: channelIds,
};
}
case ViewTypes.REMOVE_LAST_CHANNEL_FOR_TEAM: {
const {data} = action;
const team = state[data.teamId];
if (!data.channelId) {
return state;
}
if (team) {
const channelIds = [...team];
const index = channelIds.indexOf(data.channelId);
if (index !== -1) {
channelIds.splice(index, 1);
}
return {
...state,
[data.teamId]: channelIds,
};
}
return state;
}
default:
return state;
}

View File

@@ -118,7 +118,6 @@ function handleReceiveUploadFiles(state, action) {
return {
...file,
localPath: tempFile.localPath,
loading: false,
};
}

View File

@@ -17,7 +17,6 @@ export default class ChannelTitle extends PureComponent {
static propTypes = {
currentChannelName: PropTypes.string,
displayName: PropTypes.string,
isChannelMuted: PropTypes.bool,
onPress: PropTypes.func,
theme: PropTypes.object,
};
@@ -29,7 +28,7 @@ export default class ChannelTitle extends PureComponent {
};
render() {
const {currentChannelName, displayName, isChannelMuted, onPress, theme} = this.props;
const {currentChannelName, displayName, onPress, theme} = this.props;
const channelName = displayName || currentChannelName;
const style = getStyle(theme);
let icon;
@@ -43,17 +42,6 @@ export default class ChannelTitle extends PureComponent {
);
}
let mutedIcon;
if (isChannelMuted) {
mutedIcon = (
<Icon
style={[style.icon, style.muted]}
size={15}
name='bell-slash-o'
/>
);
}
return (
<TouchableOpacity
style={style.container}
@@ -68,7 +56,6 @@ export default class ChannelTitle extends PureComponent {
{channelName}
</Text>
{icon}
{mutedIcon}
</View>
</TouchableOpacity>
);
@@ -87,7 +74,6 @@ const getStyle = makeStyleSheetFromTheme((theme) => {
top: -1,
flexDirection: 'row',
justifyContent: 'flex-start',
width: '90%',
},
icon: {
color: theme.sidebarHeaderTextColor,
@@ -99,10 +85,5 @@ const getStyle = makeStyleSheetFromTheme((theme) => {
fontWeight: 'bold',
textAlign: 'center',
},
muted: {
marginTop: 1,
opacity: 0.6,
marginLeft: 0,
},
};
});

View File

@@ -3,20 +3,17 @@
import {connect} from 'react-redux';
import {getCurrentChannel, getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {isChannelMuted} from 'mattermost-redux/utils/channel_utils';
import ChannelTitle from './channel_title';
function mapStateToProps(state) {
const currentChannel = getCurrentChannel(state);
const myChannelMember = getMyCurrentChannelMembership(state);
return {
currentChannelName: currentChannel ? currentChannel.display_name : '',
displayName: state.views.channel.displayName,
isChannelMuted: isChannelMuted(myChannelMember),
theme: getTheme(state),
};
}

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import PropTypes from 'prop-types';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
StyleSheet,
@@ -10,11 +10,7 @@ import {
InteractionManager,
} from 'react-native';
import {debounce} from 'mattermost-redux/actions/helpers';
import AnnouncementBanner from 'app/components/announcement_banner';
import ChannelIntro from 'app/components/channel_intro';
import LoadMorePosts from 'app/components/load_more_posts';
import PostList from 'app/components/post_list';
import PostListRetry from 'app/components/post_list_retry';
import RetryBarIndicator from 'app/components/retry_bar_indicator';
@@ -118,41 +114,18 @@ export default class ChannelPostList extends PureComponent {
}
};
loadMorePosts = debounce(() => {
loadMorePosts = () => {
if (this.props.loadMorePostsVisible) {
const {actions, channelId} = this.props;
actions.increasePostVisibility(channelId);
}
}, 100);
};
loadPostsRetry = () => {
const {actions, channelId} = this.props;
actions.loadPostsIfNecessaryWithRetry(channelId);
};
renderFooter = () => {
if (!this.props.channelId) {
return null;
}
if (this.props.loadMorePostsVisible) {
return (
<LoadMorePosts
channelId={this.props.channelId}
loadMore={this.loadMorePosts}
theme={this.props.theme}
/>
);
}
return (
<ChannelIntro
channelId={this.props.channelId}
navigator={this.props.navigator}
/>
);
};
render() {
const {
actions,
@@ -188,8 +161,8 @@ export default class ChannelPostList extends PureComponent {
component = (
<PostList
postIds={visiblePostIds}
extraData={loadMorePostsVisible}
onEndReached={this.loadMorePosts}
loadMore={this.loadMorePosts}
showLoadMore={loadMorePostsVisible}
onPostPress={this.goToThread}
onRefresh={actions.setChannelRefreshing}
renderReplies={true}
@@ -198,7 +171,6 @@ export default class ChannelPostList extends PureComponent {
lastViewedAt={lastViewedAt}
channelId={channelId}
navigator={navigator}
renderFooter={this.renderFooter}
/>
);
}

View File

@@ -270,7 +270,6 @@ class ChannelAddMembers extends PureComponent {
onChangeText={this.searchProfiles}
onSearchButtonPress={this.searchProfiles}
onCancelButtonPress={this.cancelSearch}
autoCapitalize='none'
value={term}
/>
</View>

View File

@@ -35,17 +35,14 @@ export default class ChannelInfo extends PureComponent {
unfavoriteChannel: PropTypes.func.isRequired,
getCustomEmojisInText: PropTypes.func.isRequired,
selectFocusedPostId: PropTypes.func.isRequired,
updateChannelNotifyProps: PropTypes.func.isRequired,
}),
canDeleteChannel: PropTypes.bool.isRequired,
currentChannel: PropTypes.object.isRequired,
currentChannelCreatorName: PropTypes.string,
currentChannelMemberCount: PropTypes.number,
currentUserId: PropTypes.string,
navigator: PropTypes.object,
status: PropTypes.string,
theme: PropTypes.object.isRequired,
isChannelMuted: PropTypes.bool.isRequired,
isCurrent: PropTypes.bool.isRequired,
isFavorite: PropTypes.bool.isRequired,
canManageUsers: PropTypes.bool.isRequired,
@@ -61,7 +58,6 @@ export default class ChannelInfo extends PureComponent {
this.state = {
isFavorite: props.isFavorite,
isMuted: props.isChannelMuted,
};
}
@@ -75,17 +71,10 @@ export default class ChannelInfo extends PureComponent {
setNavigatorStyles(this.props.navigator, nextProps.theme);
}
let isFavorite = this.state.isFavorite;
if (isFavorite !== nextProps.isFavorite) {
isFavorite = nextProps.isFavorite;
const isFavorite = nextProps.isFavorite;
if (isFavorite !== this.state.isFavorite) {
this.setState({isFavorite});
}
let isMuted = this.state.isMuted;
if (isMuted !== nextProps.isChannelMuted) {
isMuted = nextProps.isChannelMuted;
}
this.setState({isFavorite, isMuted});
}
close = () => {
@@ -264,17 +253,6 @@ export default class ChannelInfo extends PureComponent {
this.showPermalinkView(postId);
};
handleMuteChannel = () => {
const {actions, currentChannel, currentUserId, isChannelMuted} = this.props;
const {updateChannelNotifyProps} = actions;
const opts = {
mark_unread: isChannelMuted ? 'all' : 'mention',
};
this.setState({isMuted: !isChannelMuted});
updateChannelNotifyProps(currentUserId, currentChannel.id, opts);
};
showPermalinkView = (postId) => {
const {actions, navigator} = this.props;
@@ -387,16 +365,6 @@ export default class ChannelInfo extends PureComponent {
togglable={true}
theme={theme}
/>
<View style={style.separator}/>
<ChannelInfoRow
action={this.handleMuteChannel}
defaultMessage='Mute channel'
detail={this.state.isMuted}
icon='bell-slash-o'
textId='channel_notifications.muteChannel.settings'
togglable={true}
theme={theme}
/>
{
/**

View File

@@ -4,35 +4,27 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {
favoriteChannel,
getChannelStats,
deleteChannel,
unfavoriteChannel,
updateChannelNotifyProps,
} from 'mattermost-redux/actions/channels';
import {getCustomEmojisInText} from 'mattermost-redux/actions/emojis';
import {selectFocusedPostId} from 'mattermost-redux/actions/posts';
import {General} from 'mattermost-redux/constants';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {
canManageChannelMembers,
getCurrentChannel,
getCurrentChannelStats,
getSortedFavoriteChannelIds,
getMyCurrentChannelMembership,
isCurrentChannelReadOnly,
} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId, getUser, getStatusForUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import {getUserIdFromChannelName, isChannelMuted, showDeleteOption, showManagementOptions} from 'mattermost-redux/utils/channel_utils';
import {isAdmin as checkIsAdmin, isChannelAdmin as checkIsChannelAdmin, isSystemAdmin as checkIsSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {
closeDMChannel,
closeGMChannel,
leaveChannel,
loadChannelsByTeamName,
} from 'app/actions/views/channel';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCustomEmojisInText} from 'mattermost-redux/actions/emojis';
import {favoriteChannel, getChannelStats, deleteChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels';
import {selectFocusedPostId} from 'mattermost-redux/actions/posts';
import {General} from 'mattermost-redux/constants';
import {
getCurrentChannel,
getCurrentChannelStats,
getSortedFavoriteChannelIds,
canManageChannelMembers,
} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId, getUser, getStatusForUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import {getUserIdFromChannelName, showDeleteOption, showManagementOptions} from 'mattermost-redux/utils/channel_utils';
import {isAdmin, isChannelAdmin, isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import ChannelInfo from './channel_info';
@@ -43,7 +35,6 @@ function mapStateToProps(state) {
const currentChannelCreatorName = currentChannelCreator && currentChannelCreator.username;
const currentChannelStats = getCurrentChannelStats(state);
const currentChannelMemberCount = currentChannelStats && currentChannelStats.member_count;
const currentChannelMember = getMyCurrentChannelMembership(state);
const currentUserId = getCurrentUserId(state);
const favoriteChannels = getSortedFavoriteChannelIds(state);
const isCurrent = currentChannel.id === state.entities.channels.currentChannelId;
@@ -57,21 +48,12 @@ function mapStateToProps(state) {
status = getStatusForUserId(state, teammateId);
}
const isAdmin = checkIsAdmin(roles);
const isChannelAdmin = checkIsChannelAdmin(roles);
const isSystemAdmin = checkIsSystemAdmin(roles);
const channelIsReadOnly = isCurrentChannelReadOnly(state);
const canEditChannel = !channelIsReadOnly && showManagementOptions(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin);
return {
canDeleteChannel: showDeleteOption(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin),
canEditChannel,
canDeleteChannel: showDeleteOption(config, license, currentChannel, isAdmin(roles), isSystemAdmin(roles), isChannelAdmin(roles)),
canEditChannel: showManagementOptions(config, license, currentChannel, isAdmin(roles), isSystemAdmin(roles), isChannelAdmin(roles)),
currentChannel,
currentChannelCreatorName,
currentChannelMemberCount,
currentUserId,
isChannelMuted: isChannelMuted(currentChannelMember),
isCurrent,
isFavorite,
status,
@@ -93,7 +75,6 @@ function mapDispatchToProps(dispatch) {
unfavoriteChannel,
getCustomEmojisInText,
selectFocusedPostId,
updateChannelNotifyProps,
}, dispatch),
};
}

View File

@@ -28,7 +28,7 @@ class ChannelMembers extends PureComponent {
intl: intlShape.isRequired,
theme: PropTypes.object.isRequired,
currentChannel: PropTypes.object,
currentChannelMembers: PropTypes.array,
currentChannelMembers: PropTypes.array.isRequired,
currentUserId: PropTypes.string.isRequired,
navigator: PropTypes.object,
requestStatus: PropTypes.string,
@@ -317,7 +317,6 @@ class ChannelMembers extends PureComponent {
onChangeText={this.searchProfiles}
onSearchButtonPress={this.searchProfiles}
onCancelButtonPress={this.cancelSearch}
autoCapitalize='none'
value={term}
/>
</View>

View File

@@ -7,31 +7,21 @@ import {connect} from 'react-redux';
import {handleRemoveChannelMembers} from 'app/actions/views/channel_members';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentChannel, canManageChannelMembers} from 'mattermost-redux/selectors/entities/channels';
import {makeGetProfilesInChannel} from 'mattermost-redux/selectors/entities/users';
import {getProfilesInCurrentChannel} from 'mattermost-redux/selectors/entities/users';
import {getProfilesInChannel, searchProfiles} from 'mattermost-redux/actions/users';
import ChannelMembers from './channel_members';
function makeMapStateToProps() {
const getChannelMembers = makeGetProfilesInChannel();
return (state) => {
const currentChannel = getCurrentChannel(state) || {};
let currentChannelMembers = [];
if (currentChannel) {
currentChannelMembers = getChannelMembers(state, currentChannel.id, true);
}
return {
theme: getTheme(state),
currentChannel,
currentChannelMembers,
currentUserId: state.entities.users.currentUserId,
requestStatus: state.requests.users.getProfilesInChannel.status,
searchRequestStatus: state.requests.users.searchProfiles.status,
removeMembersStatus: state.requests.channels.removeChannelMember.status,
canManageUsers: canManageChannelMembers(state),
};
function mapStateToProps(state) {
return {
theme: getTheme(state),
currentChannel: getCurrentChannel(state) || {},
currentChannelMembers: getProfilesInCurrentChannel(state),
currentUserId: state.entities.users.currentUserId,
requestStatus: state.requests.users.getProfilesInChannel.status,
searchRequestStatus: state.requests.users.searchProfiles.status,
removeMembersStatus: state.requests.channels.removeChannelMember.status,
canManageUsers: canManageChannelMembers(state),
};
}
@@ -45,4 +35,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(ChannelMembers);
export default connect(mapStateToProps, mapDispatchToProps)(ChannelMembers);

View File

@@ -79,6 +79,7 @@ export default class ChannelPeek extends PureComponent {
<View style={style.container}>
<PostList
postIds={visiblePostIds}
showLoadMore={false}
renderReplies={true}
indicateNewMessages={true}
currentUserId={currentUserId}

View File

@@ -4,7 +4,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import {
Platform,
ScrollView,
StyleSheet,
Text,
@@ -64,10 +63,7 @@ export default class Code extends React.PureComponent {
contentContainerStyle={style.code}
horizontal={true}
>
<Text
selectable={true}
style={style.codeText}
>
<Text style={style.codeText}>
{this.props.content}
</Text>
</ScrollView>
@@ -110,23 +106,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
code: {
paddingHorizontal: 6,
...Platform.select({
android: {
paddingVertical: 4,
},
}),
paddingVertical: 4,
},
codeText: {
color: changeOpacity(theme.centerChannelColor, 0.65),
fontFamily: getCodeFont(),
fontSize: 12,
lineHeight: 18,
...Platform.select({
ios: {
margin: 0,
padding: 0,
},
}),
},
};
});

View File

@@ -11,7 +11,7 @@ import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import Loading from 'app/components/loading';
import ErrorText from 'app/components/error_text';
import StatusBar from 'app/components/status_bar/index';
import ProfilePicture from 'app/components/profile_picture';
import ProfilePicture from 'app/components/profile_picture/index';
import AttachmentButton from 'app/components/attachment_button';
import {emptyFunction} from 'app/utils/general';
import {preventDoubleTap} from 'app/utils/tap';

View File

@@ -1,276 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import {
Keyboard,
FlatList,
StyleSheet,
SafeAreaView,
View,
} from 'react-native';
import ChannelLoader from 'app/components/channel_loader';
import DateHeader from 'app/components/post_list/date_header';
import FailedNetworkAction from 'app/components/failed_network_action';
import NoResults from 'app/components/no_results';
import PostSeparator from 'app/components/post_separator';
import StatusBar from 'app/components/status_bar';
import mattermostManaged from 'app/mattermost_managed';
import SearchResultPost from 'app/screens/search/search_result_post';
import ChannelDisplayName from 'app/screens/search/channel_display_name';
import {DATE_LINE} from 'app/selectors/post_list';
import {changeOpacity} from 'app/utils/theme';
export default class FlaggedPosts extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
clearSearch: PropTypes.func.isRequired,
loadChannelsByTeamName: PropTypes.func.isRequired,
loadThreadIfNecessary: PropTypes.func.isRequired,
getFlaggedPosts: PropTypes.func.isRequired,
selectFocusedPostId: PropTypes.func.isRequired,
selectPost: PropTypes.func.isRequired,
}).isRequired,
didFail: PropTypes.bool,
isLoading: PropTypes.bool,
navigator: PropTypes.object,
postIds: PropTypes.array,
theme: PropTypes.object.isRequired,
};
static defaultProps = {
postIds: [],
};
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props) {
super(props);
props.navigator.setOnNavigatorEvent(this.onNavigatorEvent);
props.actions.clearSearch();
props.actions.getFlaggedPosts();
this.state = {
managedConfig: {},
};
}
componentWillMount() {
this.listenerId = mattermostManaged.addEventListener('change', this.setManagedConfig);
}
componentDidMount() {
this.setManagedConfig();
}
componentWillUnmount() {
mattermostManaged.removeEventListener(this.listenerId);
}
goToThread = (post) => {
const {actions, navigator, theme} = this.props;
const channelId = post.channel_id;
const rootId = (post.root_id || post.id);
Keyboard.dismiss();
actions.loadThreadIfNecessary(rootId, channelId);
actions.selectPost(rootId);
const options = {
screen: 'Thread',
animated: true,
backButtonTitle: '',
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg,
},
passProps: {
channelId,
rootId,
},
};
navigator.push(options);
};
handleClosePermalink = () => {
const {actions} = this.props;
actions.selectFocusedPostId('');
this.showingPermalink = false;
};
handlePermalinkPress = (postId, teamName) => {
this.props.actions.loadChannelsByTeamName(teamName);
this.showPermalinkView(postId, true);
};
keyExtractor = (item) => item;
onNavigatorEvent = (event) => {
if (event.type === 'NavBarButtonPress') {
if (event.id === 'close-settings') {
this.props.navigator.dismissModal({
animationType: 'slide-down',
});
}
}
};
previewPost = (post) => {
Keyboard.dismiss();
this.showPermalinkView(post.id, false);
};
renderEmpty = () => {
const {formatMessage} = this.context.intl;
const {theme} = this.props;
return (
<NoResults
description={formatMessage({
id: 'mobile.flagged_posts.empty_description',
defaultMessage: 'Flags are a way to mark messages for follow up. Your flags are personal, and cannot be seen by other users.',
})}
iconName='ios-flag-outline'
title={formatMessage({id: 'mobile.flagged_posts.empty_title', defaultMessage: 'No Flagged Posts'})}
theme={theme}
/>
);
};
renderPost = ({item, index}) => {
const {postIds, theme} = this.props;
const {managedConfig} = this.state;
if (item.indexOf(DATE_LINE) === 0) {
const date = new Date(item.substring(DATE_LINE.length));
return (
<DateHeader
date={date}
index={index}
/>
);
}
let separator;
const nextPost = postIds[index + 1];
if (nextPost && nextPost.indexOf(DATE_LINE) === -1) {
separator = <PostSeparator theme={theme}/>;
}
return (
<View>
<ChannelDisplayName postId={item}/>
<SearchResultPost
postId={item}
previewPost={this.previewPost}
goToThread={this.goToThread}
navigator={this.props.navigator}
onPermalinkPress={this.handlePermalinkPress}
managedConfig={managedConfig}
showFullDate={false}
/>
{separator}
</View>
);
};
setManagedConfig = async (config) => {
let nextConfig = config;
if (!nextConfig) {
nextConfig = await mattermostManaged.getLocalConfig();
}
this.setState({
managedConfig: nextConfig,
});
};
showPermalinkView = (postId, isPermalink) => {
const {actions, navigator} = this.props;
actions.selectFocusedPostId(postId);
if (!this.showingPermalink) {
const options = {
screen: 'Permalink',
animationType: 'none',
backButtonTitle: '',
overrideBackPress: true,
navigatorStyle: {
navBarHidden: true,
screenBackgroundColor: changeOpacity('#000', 0.2),
modalPresentationStyle: 'overCurrentContext',
},
passProps: {
isPermalink,
onClose: this.handleClosePermalink,
onPermalinkPress: this.handlePermalinkPress,
},
};
this.showingPermalink = true;
navigator.showModal(options);
}
};
retry = () => {
this.props.actions.getFlaggedPosts();
};
render() {
const {didFail, isLoading, postIds, theme} = this.props;
let component;
if (didFail) {
component = (
<FailedNetworkAction
onRetry={this.retry}
theme={theme}
/>
);
} else if (isLoading) {
component = (
<ChannelLoader channelIsLoading={true}/>
);
} else if (postIds.length) {
component = (
<FlatList
ref='list'
contentContainerStyle={style.sectionList}
data={postIds}
keyExtractor={this.keyExtractor}
keyboardShouldPersistTaps='always'
keyboardDismissMode='interactive'
renderItem={this.renderPost}
/>
);
} else {
component = this.renderEmpty();
}
return (
<SafeAreaView style={style.container}>
<View style={style.container}>
<StatusBar/>
{component}
</View>
</SafeAreaView>
);
}
}
const style = StyleSheet.create({
container: {
flex: 1,
},
});

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {selectFocusedPostId, selectPost} from 'mattermost-redux/actions/posts';
import {clearSearch, getFlaggedPosts} from 'mattermost-redux/actions/search';
import {RequestStatus} from 'mattermost-redux/constants';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {loadChannelsByTeamName, loadThreadIfNecessary} from 'app/actions/views/channel';
import {makePreparePostIdsForSearchPosts} from 'app/selectors/post_list';
import FlaggedPosts from './flagged_posts';
function makeMapStateToProps() {
const preparePostIds = makePreparePostIdsForSearchPosts();
return (state) => {
const postIds = preparePostIds(state, state.entities.search.flagged);
const {flaggedPosts: flaggedPostsRequest} = state.requests.search;
const isLoading = flaggedPostsRequest.status === RequestStatus.STARTED ||
flaggedPostsRequest.status === RequestStatus.NOT_STARTED;
return {
postIds,
isLoading,
didFail: flaggedPostsRequest.status === RequestStatus.FAILURE,
theme: getTheme(state),
};
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
clearSearch,
loadChannelsByTeamName,
loadThreadIfNecessary,
getFlaggedPosts,
selectFocusedPostId,
selectPost,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(FlaggedPosts);

View File

@@ -15,12 +15,9 @@ import {intlShape} from 'react-intl';
import {Client4} from 'mattermost-redux/client';
import {DeviceTypes} from 'app/constants/';
import FormattedText from 'app/components/formatted_text';
import {isDocument, isVideo} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
const {DOCUMENTS_PATH, VIDEOS_PATH} = DeviceTypes;
const EXTERNAL_STORAGE_PERMISSION = 'android.permission.WRITE_EXTERNAL_STORAGE';
const HEADER_HEIGHT = 64;
const OPTION_LIST_WIDTH = 39;
@@ -64,7 +61,6 @@ export default class Downloader extends PureComponent {
handleDownload = async () => {
const {file, onDownloadCancel, onDownloadStart, onDownloadSuccess} = this.props;
const {intl} = this.context;
const {data} = file;
const canWriteToStorage = await this.checkForPermissions();
if (!canWriteToStorage) {
@@ -89,54 +85,24 @@ export default class Downloader extends PureComponent {
ToastAndroid.show(started, ToastAndroid.SHORT);
onDownloadStart();
const dest = `${RNFetchBlob.fs.dirs.DownloadDir}/${data.id}-${file.caption}`;
let downloadFile = true;
const imageUrl = Client4.getFileUrl(file.id);
if (data.localPath) {
const exists = await RNFetchBlob.fs.exists(data.localPath);
const task = RNFetchBlob.config({
fileCache: true,
addAndroidDownloads: {
useDownloadManager: true,
notification: true,
path: `${RNFetchBlob.fs.dirs.DownloadDir}/${file.name}`,
title: `${file.name} ${title}`,
mime: file.mime_type,
description: file.name,
mediaScannable: true,
},
}).fetch('GET', imageUrl, {
Authorization: `Bearer ${Client4.token}`,
});
if (exists) {
downloadFile = false;
await RNFetchBlob.fs.cp(data.localPath, dest);
}
} else if (isVideo(data)) {
const path = `${VIDEOS_PATH}/${data.id}-${file.caption}`;
const exists = await RNFetchBlob.fs.exists(path);
if (exists) {
downloadFile = false;
await RNFetchBlob.fs.cp(path, dest);
}
} else if (isDocument(data)) {
const path = `${DOCUMENTS_PATH}/${data.id}-${file.caption}`;
const exists = await RNFetchBlob.fs.exists(path);
if (exists) {
downloadFile = false;
await RNFetchBlob.fs.cp(path, dest);
}
}
if (downloadFile) {
const imageUrl = Client4.getFileUrl(data.id);
const task = RNFetchBlob.config({
fileCache: true,
addAndroidDownloads: {
useDownloadManager: true,
notification: true,
path: dest,
title: `${file.caption} ${title}`,
mime: data.mime_type,
description: data.name,
mediaScannable: true,
},
}).fetch('GET', imageUrl, {
Authorization: `Bearer ${Client4.token}`,
});
await task;
}
await task;
ToastAndroid.show(complete, ToastAndroid.SHORT);
onDownloadSuccess();

View File

@@ -261,66 +261,50 @@ export default class Downloader extends PureComponent {
startDownload = async () => {
const {file, downloadPath, prompt, saveToCameraRoll} = this.props;
const {data} = file;
let downloadFile = true;
try {
if (this.state.didCancel) {
this.setState({didCancel: false});
}
let path;
let res;
if (data && data.localPath) {
path = data.localPath;
downloadFile = false;
this.setState({
progress: 100,
started: true,
});
}
const imageUrl = Client4.getFileUrl(file.id);
const options = {
session: file.id,
timeout: 10000,
indicator: true,
overwrite: true,
};
if (downloadFile) {
const imageUrl = Client4.getFileUrl(data.id);
const options = {
session: data.id,
timeout: 10000,
indicator: true,
overwrite: true,
};
if (downloadPath && prompt) {
const isDir = await RNFetchBlob.fs.isDir(downloadPath);
if (!isDir) {
try {
await RNFetchBlob.fs.mkdir(downloadPath);
} catch (error) {
this.showDownloadFailedAlert();
return;
}
if (downloadPath && prompt) {
const isDir = await RNFetchBlob.fs.isDir(downloadPath);
if (!isDir) {
try {
await RNFetchBlob.fs.mkdir(downloadPath);
} catch (error) {
this.showDownloadFailedAlert();
return;
}
options.path = `${downloadPath}/${data.id}-${file.caption}`;
} else {
options.fileCache = true;
options.appendExt = data.extension;
}
this.downloadTask = RNFetchBlob.config(options).fetch('GET', imageUrl);
this.downloadTask.progress((received, total) => {
const progress = (received / total) * 100;
if (this.mounted) {
this.setState({
progress,
started: true,
});
}
});
res = await this.downloadTask;
path = res.path();
options.path = `${downloadPath}/${file.id}.${file.extension}`;
} else {
options.fileCache = true;
options.appendExt = file.extension;
}
this.downloadTask = RNFetchBlob.config(options).fetch('GET', imageUrl);
this.downloadTask.progress((received, total) => {
const progress = (received / total) * 100;
if (this.mounted) {
this.setState({
progress,
started: true,
});
}
});
const res = await this.downloadTask;
let path = res.path();
if (saveToCameraRoll) {
path = await CameraRoll.saveToCameraRoll(path, 'photo');
}
@@ -344,15 +328,14 @@ export default class Downloader extends PureComponent {
});
}
if (saveToCameraRoll && res) {
if (saveToCameraRoll) {
res.flush(); // remove the temp file
}
this.downloadTask = null;
} catch (error) {
// cancellation throws so we need to catch
if (downloadPath) {
RNFetchBlob.fs.unlink(`${downloadPath}/${data.id}-${file.caption}`);
RNFetchBlob.fs.unlink(`${downloadPath}/${file.id}.${file.extension}`);
}
if (error.message !== 'cancelled' && this.mounted) {
this.showDownloadFailedAlert();
@@ -379,7 +362,7 @@ export default class Downloader extends PureComponent {
render() {
const {show, downloadPath} = this.props;
if (!show && !this.state.force) {
if ((!show || this.state.didCancel) && !this.state.force) {
return null;
}

View File

@@ -6,9 +6,9 @@ import PropTypes from 'prop-types';
import {
Alert,
Animated,
InteractionManager,
PanResponder,
Platform,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
@@ -20,40 +20,49 @@ import Icon from 'react-native-vector-icons/Ionicons';
import LinearGradient from 'react-native-linear-gradient';
import {intlShape} from 'react-intl';
import Permissions from 'react-native-permissions';
import Gallery from 'react-native-image-gallery';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {DeviceTypes} from 'app/constants/';
import FileAttachmentDocument from 'app/components/file_attachment_list/file_attachment_document';
import FileAttachmentDocument, {SUPPORTED_DOCS_FORMAT} from 'app/components/file_attachment_list/file_attachment_document';
import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon';
import SafeAreaView from 'app/components/safe_area_view';
import Swiper from 'app/components/swiper';
import {NavigationTypes, PermissionTypes} from 'app/constants';
import {isDocument, isVideo} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
import Downloader from './downloader';
import Previewer from './previewer';
import VideoPreview from './video_preview';
import ProgressiveImage from 'app/components/progressive_image';
const {VIDEOS_PATH} = DeviceTypes;
const {View: AnimatedView} = Animated;
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
const HEADER_HEIGHT = 48;
const ANIM_CONFIG = {duration: 300};
const DRAG_VERTICAL_THRESHOLD_START = 25; // When do we want to start capturing the drag
const DRAG_VERTICAL_THRESHOLD_END = 100; // When do we want to navigate back
const DRAG_HORIZONTAL_THRESHOLD = 50; // Make sure that it's not a sloppy horizontal swipe
const HEADER_HEIGHT = 64;
const STATUSBAR_HEIGHT = Platform.select({
ios: 0,
android: 20,
});
const SUPPORTED_VIDEO_FORMAT = Platform.select({
ios: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm'],
});
export default class ImagePreview extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
addFileToFetchCache: PropTypes.func.isRequired,
}),
canDownloadFiles: PropTypes.bool.isRequired,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
files: PropTypes.array,
getItemMeasures: PropTypes.func.isRequired,
getPreviewProps: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
fetchCache: PropTypes.object.isRequired,
fileId: PropTypes.string.isRequired,
files: PropTypes.array.isRequired,
navigator: PropTypes.object,
origin: PropTypes.object,
target: PropTypes.object,
statusBarHeight: PropTypes.number,
theme: PropTypes.object.isRequired,
};
@@ -64,389 +73,219 @@ export default class ImagePreview extends PureComponent {
constructor(props) {
super(props);
props.navigator.setStyle({
screenBackgroundColor: '#000',
});
this.zoomableImages = {};
this.openAnim = new Animated.Value(0);
this.headerFooterAnim = new Animated.Value(1);
this.documents = [];
const currentFile = props.files.findIndex((file) => file.id === props.fileId);
this.initialPage = currentFile;
this.state = {
index: props.index,
origin: props.origin,
showDownloader: false,
target: props.target,
currentFile,
drag: new Animated.ValueXY(),
files: props.files,
footerOpacity: new Animated.Value(1),
pagingEnabled: true,
showFileInfo: true,
wrapperViewOpacity: new Animated.Value(0),
limitOpacity: new Animated.Value(0),
};
}
componentWillMount() {
this.mainViewPanResponder = PanResponder.create({
onMoveShouldSetPanResponderCapture: this.mainViewMoveShouldSetPanResponderCapture,
onPanResponderMove: Animated.event([null, {
dx: 0,
dy: this.state.drag.y,
}]),
onPanResponderRelease: this.mainViewPanResponderRelease,
onPanResponderTerminate: this.mainViewPanResponderRelease,
});
}
componentDidMount() {
this.startOpenAnimation();
InteractionManager.runAfterInteractions(() => {
Animated.timing(this.state.wrapperViewOpacity, {
toValue: 1,
duration: 100,
}).start();
});
}
componentWillReceiveProps(nextProps) {
if (!nextProps.files.length) {
this.showDeletedFilesAlert();
}
}
componentWillUnmount() {
StatusBar.setHidden(false, 'fade');
if (Platform.OS === 'ios') {
StatusBar.setHidden(false, 'fade');
}
}
animateOpenAnimToValue = (toValue, onComplete) => {
Animated.timing(this.openAnim, {
...ANIM_CONFIG,
toValue,
}).start(() => {
this.setState({animating: false});
if (onComplete) {
onComplete();
}
});
};
close = () => {
const {getItemMeasures, navigator} = this.props;
const {index} = this.state;
this.props.navigator.dismissModal({animationType: 'none'});
};
this.setState({animating: true, gallery: false, hide: false});
navigator.setStyle({
screenBackgroundColor: 'transparent',
});
getItemMeasures(index, (origin) => {
if (origin) {
this.setState(origin);
getPreviews = () => {
return this.state.files.map((file, index) => {
let mime = file.mime_type;
if (mime && mime.includes(';')) {
mime = mime.split(';')[0];
}
this.animateOpenAnimToValue(0, () => {
navigator.dismissModal({animationType: 'none'});
});
let component;
if (file.has_preview_image || file.mime_type === 'image/gif') {
component = this.renderPreviewer(file, index);
} else if (SUPPORTED_DOCS_FORMAT.includes(mime)) {
component = this.renderAttachmentDocument(file);
} else if (SUPPORTED_VIDEO_FORMAT.includes(file.mime_type)) {
component = this.renderVideoPreview(file);
} else {
component = this.renderAttachmentIcon(file);
}
return (
<AnimatedView
key={file.id}
style={[style.pageWrapper, {height: this.props.deviceHeight, width: this.props.deviceWidth, opacity: index === this.state.currentFile ? 1 : this.state.limitOpacity}]}
>
{component}
</AnimatedView>
);
});
};
handleChangeImage = (index) => {
this.setState({index});
};
handleGalleryLayout = () => {
this.setState({hide: true});
};
handleSwipedVertical = (evt, gestureState) => {
if (Math.abs(gestureState.dy) > 150) {
handleClose = () => {
if (this.state.showFileInfo) {
this.close();
}
};
handleTapped = () => {
const {showHeaderFooter} = this.state;
this.setHeaderAndFooterVisible(!showHeaderFooter);
};
hideDownloader = (hideFileInfo = true) => {
this.setState({showDownloader: false});
if (hideFileInfo) {
this.setHeaderAndFooterVisible(true);
this.setHeaderAndFileInfoVisible(true);
}
};
getCurrentFile = () => {
const {files} = this.props;
const {index} = this.state;
const file = files[index];
return file;
handleLayout = () => {
if (this.refs.swiper) {
this.refs.swiper.runOnLayout = true;
}
};
getFullscreenOpacity = () => {
const {target} = this.props;
return {
opacity: this.openAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, target.opacity],
}),
};
handleImageDoubleTap = (x, y) => {
this.zoomableImages[this.state.currentFile].toggleZoom(x, y);
};
getHeaderFooterStyle = () => {
return {
start: this.headerFooterAnim.interpolate({
inputRange: [0, 1],
outputRange: [-80, 0],
}),
opacity: this.headerFooterAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
}),
};
handleImageTap = () => {
this.hideDownloader(false);
this.setHeaderAndFileInfoVisible(!this.state.showFileInfo);
};
getSwipeableStyle = () => {
const {deviceHeight, deviceWidth} = this.props;
const {origin, target} = this.state;
const inputRange = [0, 1];
return {
left: this.openAnim.interpolate({
inputRange,
outputRange: [origin.x, target.x],
}),
top: this.openAnim.interpolate({
inputRange,
outputRange: [origin.y, target.y],
}),
width: this.openAnim.interpolate({
inputRange,
outputRange: [origin.width, deviceWidth],
}),
height: this.openAnim.interpolate({
inputRange,
outputRange: [origin.height, deviceHeight],
}),
};
handleIndexChanged = (currentFile) => {
if (Number.isInteger(currentFile)) {
this.setState({currentFile, limitOpacity: new Animated.Value(0)});
}
};
renderAttachmentDocument = (file) => {
const {theme, navigator} = this.props;
return (
<View style={[style.flex, style.center]}>
<FileAttachmentDocument
ref={(ref) => {
this.documents[this.state.index] = ref;
}}
file={file}
theme={theme}
navigator={navigator}
iconHeight={120}
iconWidth={120}
wrapperHeight={200}
wrapperWidth={200}
/>
</View>
);
handleScroll = () => {
Animated.timing(this.state.limitOpacity, {
toValue: 1,
duration: 100,
}).start();
};
renderAttachmentIcon = (file) => {
return (
<View style={[style.flex, style.center]}>
<FileAttachmentIcon
file={file}
theme={this.props.theme}
iconHeight={120}
iconWidth={120}
wrapperHeight={200}
wrapperWidth={200}
/>
</View>
);
handleVideoSeek = (seeking) => {
this.setState({
isZooming: !seeking,
});
};
renderDownloadButton = () => {
const {canDownloadFiles} = this.props;
const file = this.getCurrentFile();
imageIsZooming = (zooming) => {
if (zooming !== this.state.isZooming) {
this.setHeaderAndFileInfoVisible(!zooming);
this.setState({
isZooming: zooming,
});
}
};
if (file) {
let icon;
let action = emptyFunction;
if (canDownloadFiles) {
action = this.showDownloadOptions;
if (Platform.OS === 'android') {
icon = (
<Icon
name='md-more'
size={32}
color='#fff'
/>
);
} else if (file.source || isVideo(file.data)) {
icon = (
<Icon
name='ios-download-outline'
size={26}
color='#fff'
/>
);
}
}
return (
<TouchableOpacity
onPress={action}
style={style.headerIcon}
>
{icon}
</TouchableOpacity>
);
mainViewMoveShouldSetPanResponderCapture = (evt, gestureState) => {
if (gestureState.numberActiveTouches === 2 || this.state.isZooming) {
return false;
}
return null;
};
renderDownloader() {
const {deviceHeight, deviceWidth} = this.props;
const file = this.getCurrentFile();
return (
<Downloader
ref='downloader'
show={this.state.showDownloader}
file={file}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
onDownloadCancel={this.hideDownloader}
onDownloadStart={this.hideDownloader}
onDownloadSuccess={this.hideDownloader}
/>
);
}
renderFooter() {
const {files} = this.props;
const {index} = this.state;
const footer = this.getHeaderFooterStyle();
return (
<Animated.View style={[{bottom: footer.start, opacity: footer.opacity}, style.footerContainer]}>
<LinearGradient
style={style.footer}
start={{x: 0.0, y: 0.0}}
end={{x: 0.0, y: 0.9}}
colors={['transparent', '#000000']}
pointerEvents='none'
>
<Text style={style.filename}>
{(files[index] && files[index].caption) || ''}
</Text>
</LinearGradient>
</Animated.View>
);
}
renderGallery() {
return (
<Gallery
errorComponent={this.renderOtherItems}
images={this.props.files}
initialPage={this.state.index}
onLayout={this.handleGalleryLayout}
onPageSelected={this.handleChangeImage}
onSingleTapConfirmed={this.handleTapped}
onSwipedVertical={this.handleSwipedVertical}
pageMargin={2}
style={style.flex}
/>
);
}
renderHeader() {
const {files} = this.props;
const {index} = this.state;
const header = this.getHeaderFooterStyle();
return (
<AnimatedView style={[style.headerContainer, {top: header.start, opacity: header.opacity}]}>
<View style={style.header}>
<View style={style.headerControls}>
<TouchableOpacity
onPress={this.close}
style={style.headerIcon}
>
<Icon
name='md-close'
size={26}
color='#fff'
/>
</TouchableOpacity>
<Text style={style.title}>
{`${index + 1}/${files.length}`}
</Text>
{this.renderDownloadButton()}
</View>
</View>
</AnimatedView>
);
}
renderOtherItems = (index) => {
const {files} = this.props;
const file = files[index];
if (file.data) {
if (isDocument(file.data)) {
return this.renderAttachmentDocument(file);
} else if (isVideo(file.data)) {
return this.renderVideoPreview(file);
}
return this.renderAttachmentIcon(file.data);
const {dx, dy} = gestureState;
const isVerticalDrag = Math.abs(dy) > DRAG_VERTICAL_THRESHOLD_START && dx < DRAG_HORIZONTAL_THRESHOLD;
if (isVerticalDrag) {
this.setHeaderAndFileInfoVisible(false);
return true;
}
return <View/>;
return false;
};
renderSelectedItem = () => {
const {hide, index} = this.state;
const file = this.getCurrentFile();
if (hide || isDocument(file.data) || isVideo(file.data)) {
return null;
mainViewPanResponderRelease = (evt, gestureState) => {
if (Math.abs(gestureState.dy) > DRAG_VERTICAL_THRESHOLD_END) {
this.close();
} else {
this.setHeaderAndFileInfoVisible(true);
Animated.spring(this.state.drag, {
toValue: {x: 0, y: 0},
}).start();
}
const {getPreviewProps} = this.props;
const containerStyle = this.getSwipeableStyle();
const previewProps = getPreviewProps(index);
Reflect.deleteProperty(previewProps, 'thumbnailUri');
return (
<ScrollView scrollEnabled={false}>
<Animated.View style={[style.center, style.flex, containerStyle]}>
<ProgressiveImage
{...previewProps}
style={[StyleSheet.absoluteFill, style.fullWidth]}
resizeMode='contain'
/>
</Animated.View>
</ScrollView>
);
};
renderVideoPreview = (file) => {
const {deviceHeight, deviceWidth, theme} = this.props;
return (
<VideoPreview
file={file}
onFullScreen={this.setHeaderAndFooterVisible}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
theme={theme}
/>
);
};
saveVideoIOS = () => {
const file = this.getCurrentFile();
const {data} = file;
saveVideo = () => {
const file = this.state.files[this.state.currentFile];
if (this.refs.downloader) {
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
this.refs.downloader.saveVideo(`${VIDEOS_PATH}/${data.id}.${data.extension}`);
this.refs.downloader.saveVideo(`${VIDEOS_PATH}/${file.id}.${file.extension}`);
}
};
setHeaderAndFooterVisible = (show) => {
const toValue = show ? 1 : 0;
setHeaderAndFileInfoVisible = (show) => {
this.setState({
showFileInfo: show,
});
if (!show) {
this.hideDownloader();
if (Platform.OS === 'ios') {
StatusBar.setHidden(!show, 'fade');
}
this.setState({showHeaderFooter: show});
StatusBar.setHidden(!show, 'slide');
const opacity = show ? 1 : 0;
Animated.timing(this.headerFooterAnim, {
...ANIM_CONFIG,
toValue,
Animated.timing(this.state.footerOpacity, {
toValue: opacity,
duration: 300,
}).start();
};
showDeletedFilesAlert = () => {
const {intl} = this.context;
Alert.alert(
intl.formatMessage({
id: 'mobile.image_preview.deleted_post_title',
defaultMessage: 'Post Deleted',
}),
intl.formatMessage({
id: 'mobile.image_preview.deleted_post_message',
defaultMessage: 'This post and its files have been deleted. The previewer will now be closed.',
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
onPress: this.close,
}]
);
};
showDownloader = () => {
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
@@ -463,13 +302,13 @@ export default class ImagePreview extends PureComponent {
this.showDownloader();
}
} else {
this.showDownloadOptionsIOS();
this.showIOSDownloadOptions();
}
};
showDownloadOptionsIOS = async () => {
showIOSDownloadOptions = async () => {
const {formatMessage} = this.context.intl;
const file = this.getCurrentFile();
const file = this.state.files[this.state.currentFile];
const items = [];
let permissionRequest;
@@ -507,19 +346,19 @@ export default class ImagePreview extends PureComponent {
}
}
if (isVideo(file.data)) {
const path = `${VIDEOS_PATH}/${file.data.id}.${file.data.extension}`;
if (SUPPORTED_VIDEO_FORMAT.includes(file.mime_type)) {
const path = `${VIDEOS_PATH}/${file.id}.${file.extension}`;
const exist = await RNFetchBlob.fs.exists(path);
if (exist) {
items.push({
action: this.saveVideoIOS,
action: this.saveVideo,
text: {
id: 'mobile.image_preview.save_video',
defaultMessage: 'Save Video',
},
});
} else {
this.showVideoDownloadRequiredAlertIOS();
this.showVideoDownloadRequiredAlert();
}
} else {
items.push({
@@ -532,13 +371,13 @@ export default class ImagePreview extends PureComponent {
}
const options = {
title: file.caption,
title: file.name,
items,
onCancelPress: () => this.setHeaderAndFooterVisible(true),
onCancelPress: () => this.setHeaderAndFileInfoVisible(true),
};
if (items.length) {
this.setHeaderAndFooterVisible(false);
this.setHeaderAndFileInfoVisible(false);
this.props.navigator.showModal({
screen: 'OptionsModal',
@@ -558,7 +397,7 @@ export default class ImagePreview extends PureComponent {
}
};
showVideoDownloadRequiredAlertIOS = () => {
showVideoDownloadRequiredAlert = () => {
const {intl} = this.context;
Alert.alert(
@@ -579,49 +418,238 @@ export default class ImagePreview extends PureComponent {
);
};
startOpenAnimation = () => {
this.animateOpenAnimToValue(1, () => {
this.setState({gallery: true});
});
renderAttachmentDocument = (file) => {
const {theme} = this.props;
return (
<FileAttachmentDocument
file={file}
theme={theme}
iconHeight={120}
iconWidth={120}
wrapperHeight={200}
wrapperWidth={200}
/>
);
};
renderAttachmentIcon = (file) => {
return (
<TouchableOpacity
activeOpacity={1}
onPress={this.handleImageTap}
>
<FileAttachmentIcon
file={file}
theme={this.props.theme}
iconHeight={120}
iconWidth={120}
wrapperHeight={200}
wrapperWidth={200}
/>
</TouchableOpacity>
);
};
renderDownloadButton = () => {
const {canDownloadFiles} = this.props;
const {currentFile, files} = this.state;
const file = files[currentFile];
if (file) {
let icon;
let action = emptyFunction;
if (canDownloadFiles) {
if (Platform.OS === 'android') {
action = this.showDownloadOptions;
icon = (
<Icon
name='md-more'
size={32}
color='#fff'
/>
);
} else if (file.has_preview_image || SUPPORTED_VIDEO_FORMAT.includes(file.mime_type)) {
action = this.showDownloadOptions;
icon = (
<Icon
name='ios-download-outline'
size={26}
color='#fff'
/>
);
}
}
return (
<TouchableOpacity
onPress={action}
style={style.headerIcon}
>
{icon}
</TouchableOpacity>
);
}
return null;
};
renderPreviewer = (file, index) => {
const maxImageHeight = this.props.deviceHeight - STATUSBAR_HEIGHT;
return (
<Previewer
ref={(c) => {
this.zoomableImages[index] = c;
}}
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
theme={this.props.theme}
imageHeight={Math.min(maxImageHeight, file.height)}
imageWidth={Math.min(this.props.deviceWidth, file.width)}
shrink={this.state.shouldShrinkImages}
wrapperHeight={this.props.deviceHeight}
wrapperWidth={this.props.deviceWidth}
onImageTap={this.handleImageTap}
onImageDoubleTap={this.handleImageDoubleTap}
onZoom={this.imageIsZooming}
/>
);
};
renderVideoPreview = (file) => {
const {deviceHeight, deviceWidth, theme} = this.props;
return (
<VideoPreview
file={file}
onFullScreen={this.handleImageTap}
onSeeking={this.handleVideoSeek}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
theme={theme}
/>
);
};
renderSwiper = () => {
return (
<Swiper
ref='swiper'
initialPage={this.initialPage}
onIndexChanged={this.handleIndexChanged}
width={this.props.deviceWidth}
activeDotColor={this.props.theme.sidebarBg}
dotColor={this.props.theme.sidebarText}
scrollEnabled={!this.state.isZooming}
showsPagination={false}
onScrollBegin={this.handleScroll}
>
{this.getPreviews()}
</Swiper>
);
};
render() {
const opacity = this.getFullscreenOpacity();
const {currentFile, files} = this.state;
const file = files[currentFile];
if (!file) {
return null;
}
const fileName = file ? file.name : '';
return (
<AnimatedSafeAreaView style={[style.container, opacity]}>
<AnimatedView style={style.container}>
{this.renderSelectedItem()}
{this.state.gallery && this.renderGallery()}
{this.renderHeader()}
{this.renderFooter()}
</AnimatedView>
{this.renderDownloader()}
</AnimatedSafeAreaView>
<SafeAreaView
backgroundColor='#000'
navBarBackgroundColor='#000'
footerColor='#000'
excludeHeader={true}
>
<View
style={[style.wrapper]}
onLayout={this.handleLayout}
>
<AnimatedView
style={[this.state.drag.getLayout(), {opacity: this.state.wrapperViewOpacity, flex: 1}]}
{...this.mainViewPanResponder.panHandlers}
>
{this.renderSwiper()}
<AnimatedView style={[style.headerContainer, {width: this.props.deviceWidth, opacity: this.state.footerOpacity}]}>
<View style={style.header}>
<View style={style.headerControls}>
<TouchableOpacity
onPress={this.handleClose}
style={style.headerIcon}
>
<Icon
name='md-close'
size={26}
color='#fff'
/>
</TouchableOpacity>
<Text style={style.title}>
{`${currentFile + 1}/${files.length}`}
</Text>
{this.renderDownloadButton()}
</View>
</View>
</AnimatedView>
<AnimatedView style={[style.footerContainer, {width: this.props.deviceWidth, opacity: this.state.footerOpacity}]}>
<LinearGradient
style={style.footer}
start={{x: 0.0, y: 0.0}}
end={{x: 0.0, y: 0.9}}
colors={['transparent', '#000000']}
pointerEvents='none'
>
<Text style={style.filename}>
{fileName}
</Text>
</LinearGradient>
</AnimatedView>
</AnimatedView>
<Downloader
ref='downloader'
show={this.state.showDownloader}
file={file}
deviceHeight={this.props.deviceHeight}
deviceWidth={this.props.deviceWidth}
onDownloadCancel={this.hideDownloader}
onDownloadStart={this.hideDownloader}
onDownloadSuccess={this.hideDownloader}
/>
</View>
</SafeAreaView>
);
}
}
const style = StyleSheet.create({
container: {
wrapper: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
},
scrollView: {
flex: 1,
backgroundColor: '#000',
},
flex: {
flex: 1,
scrollViewContent: {
backgroundColor: '#000',
},
center: {
pageWrapper: {
alignItems: 'center',
justifyContent: 'center',
},
fullWidth: {
width: '100%',
flex: 1,
},
headerContainer: {
position: 'absolute',
top: 0,
height: HEADER_HEIGHT,
width: '100%',
overflow: 'hidden',
zIndex: 2,
},
header: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
@@ -635,6 +663,7 @@ const style = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-around',
flexDirection: 'row',
marginTop: 18,
},
headerIcon: {
height: 44,
@@ -650,11 +679,10 @@ const style = StyleSheet.create({
textAlign: 'center',
},
footerContainer: {
height: 64,
justifyContent: 'center',
overflow: 'hidden',
position: 'absolute',
width: '100%',
bottom: 0,
height: 64,
zIndex: 2,
},
footer: {
position: 'absolute',

Some files were not shown because too many files have changed in this diff Show More