forked from Ivasoft/mattermost-mobile
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ba6b1d641 | ||
|
|
286f05c3be | ||
|
|
e1329d41a3 | ||
|
|
bc39b38bf4 | ||
|
|
9173752390 | ||
|
|
033241691c | ||
|
|
3a4c9e75bf | ||
|
|
840fda2051 | ||
|
|
afa18fb5d9 | ||
|
|
84de817451 | ||
|
|
93777aefe6 | ||
|
|
825d8fcad3 | ||
|
|
abc2f30ef3 | ||
|
|
0d83ac4c93 | ||
|
|
cf88a2ae8f |
@@ -1,6 +1,7 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
owasp: entur/owasp@0.0.10
|
||||
node: circleci/node@4.4.0
|
||||
|
||||
executors:
|
||||
android:
|
||||
@@ -91,6 +92,8 @@ commands:
|
||||
npm-dependencies:
|
||||
description: "Get JavaScript dependencies"
|
||||
steps:
|
||||
- node/install-npm:
|
||||
version: '6.14.11'
|
||||
- restore_cache:
|
||||
name: Restore npm cache
|
||||
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
|
||||
@@ -587,6 +590,7 @@ workflows:
|
||||
- /^build-\d+$/
|
||||
- /^build-ios-\d+$/
|
||||
- /^build-ios-beta-\d+$/
|
||||
- /^build-ios-sim-\d+$/
|
||||
|
||||
- github-release:
|
||||
context: mattermost-mobile-unsigned
|
||||
|
||||
32
NOTICE.txt
32
NOTICE.txt
@@ -2487,28 +2487,38 @@ SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## react-native-status-bar-size
|
||||
## react-native-startup-time
|
||||
|
||||
This product contains 'react-native-status-bar-size' by Brent Vatne.
|
||||
This product contains 'react-native-startup-time' by doomsower.
|
||||
|
||||
Watch and respond to changes in the iOS status bar height
|
||||
This module helps you to measure your app launch time.
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/jgkim/react-native-status-bar-size#readme
|
||||
* https://github.com/doomsower/react-native-startup-time
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
Note: An original license file for this dependency is not available. We determined the type of license based on the package registry entry for this project. The following text has been prepared using a template from the SPDX Workgroup (https://spdx.org) for this type of license.
|
||||
The MIT License (MIT)
|
||||
|
||||
MIT License
|
||||
Copyright (c) 2019 Konstantin Kuznetsov
|
||||
|
||||
Copyright (c) 2019 Brent Vatne
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -132,8 +132,8 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
|
||||
versionCode 356
|
||||
versionName "1.43.0"
|
||||
versionCode 359
|
||||
versionName "1.44.0"
|
||||
multiDexEnabled = true
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
@@ -39,6 +39,10 @@ import com.facebook.react.module.model.ReactModuleInfoProvider;
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule;
|
||||
import com.facebook.soloader.SoLoader;
|
||||
|
||||
import com.facebook.react.bridge.JSIModulePackage;
|
||||
import com.swmansion.reanimated.ReanimatedJSIModulePackage;
|
||||
|
||||
|
||||
public class MainApplication extends NavigationApplication implements INotificationsApplication, INotificationsDrawerApplication {
|
||||
public static MainApplication instance;
|
||||
|
||||
@@ -111,6 +115,11 @@ private final ReactNativeHost mReactNativeHost =
|
||||
protected String getJSMainModuleName() {
|
||||
return "index";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected JSIModulePackage getJSIModulePackage() {
|
||||
return new ReanimatedJSIModulePackage();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
|
||||
@@ -3,11 +3,9 @@ package com.mattermost.share;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.provider.MediaStore;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentResolver;
|
||||
import android.os.Environment;
|
||||
import android.webkit.MimeTypeMap;
|
||||
@@ -15,18 +13,18 @@ import android.util.Log;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.util.Objects;
|
||||
|
||||
// Class based on the steveevers DocumentHelper https://gist.github.com/steveevers/a5af24c226f44bb8fdc3
|
||||
|
||||
public class RealPathUtil {
|
||||
public static String getRealPathFromURI(final Context context, final Uri uri) {
|
||||
|
||||
final boolean isKitKatOrNewer = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
|
||||
|
||||
// DocumentProvider
|
||||
if (isKitKatOrNewer && DocumentsContract.isDocumentUri(context, uri)) {
|
||||
if (DocumentsContract.isDocumentUri(context, uri)) {
|
||||
// ExternalStorageProvider
|
||||
if (isExternalStorageDocument(uri)) {
|
||||
final String docId = DocumentsContract.getDocumentId(uri);
|
||||
@@ -111,6 +109,7 @@ public class RealPathUtil {
|
||||
int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
returnCursor.moveToFirst();
|
||||
fileName = sanitizeFilename(returnCursor.getString(nameIndex));
|
||||
returnCursor.close();
|
||||
|
||||
} catch (Exception e) {
|
||||
// just continue to get the filename with the last segment of the path
|
||||
@@ -118,7 +117,7 @@ public class RealPathUtil {
|
||||
|
||||
try {
|
||||
if (TextUtils.isEmpty(fileName)) {
|
||||
fileName = sanitizeFilename(uri.getLastPathSegment().toString().trim());
|
||||
fileName = sanitizeFilename(uri.getLastPathSegment().trim());
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +126,6 @@ public class RealPathUtil {
|
||||
cacheDir.mkdirs();
|
||||
}
|
||||
|
||||
String mimeType = getMimeType(uri.getPath());
|
||||
tmpFile = new File(cacheDir, fileName);
|
||||
tmpFile.createNewFile();
|
||||
|
||||
@@ -234,7 +232,7 @@ public class RealPathUtil {
|
||||
|
||||
private static void deleteRecursive(File fileOrDirectory) {
|
||||
if (fileOrDirectory.isDirectory())
|
||||
for (File child : fileOrDirectory.listFiles())
|
||||
for (File child : Objects.requireNonNull(fileOrDirectory.listFiles()))
|
||||
deleteRecursive(child);
|
||||
|
||||
fileOrDirectory.delete();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import EventEmmiter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import {DeviceTypes, NavigationTypes} from '@constants';
|
||||
import {CHANNEL} from '@constants/screen';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import Store from '@store/store';
|
||||
|
||||
@@ -33,8 +34,8 @@ export function resetToChannel(passProps = {}) {
|
||||
const stack = {
|
||||
children: [{
|
||||
component: {
|
||||
id: NavigationTypes.CHANNEL_SCREEN,
|
||||
name: NavigationTypes.CHANNEL_SCREEN,
|
||||
id: CHANNEL,
|
||||
name: CHANNEL,
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
@@ -363,17 +364,20 @@ export async function dismissModal(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissAllModals(options = {}) {
|
||||
export async function dismissAllModals(options) {
|
||||
if (!EphemeralStore.hasModalsOpened()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Platform.OS === 'ios') {
|
||||
const modals = [...EphemeralStore.navigationModalStack];
|
||||
for await (const modal of modals) {
|
||||
await Navigation.dismissModal(modal, options);
|
||||
EphemeralStore.removeNavigationModal(modal);
|
||||
}
|
||||
} else {
|
||||
await Navigation.dismissAllModals(options);
|
||||
EphemeralStore.clearNavigationModals();
|
||||
} catch (error) {
|
||||
// RNN returns a promise rejection if there are no modals to
|
||||
// dismiss. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +445,7 @@ export function closeMainSideMenu() {
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
Navigation.mergeOptions(CHANNEL, {
|
||||
sideMenu: {
|
||||
left: {visible: false},
|
||||
},
|
||||
@@ -453,7 +457,7 @@ export function enableMainSideMenu(enabled, visible = true) {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
Navigation.mergeOptions(CHANNEL, {
|
||||
sideMenu: {
|
||||
left: {enabled, visible},
|
||||
},
|
||||
@@ -466,7 +470,7 @@ export function openSettingsSideMenu() {
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
Navigation.mergeOptions(CHANNEL, {
|
||||
sideMenu: {
|
||||
right: {visible: true},
|
||||
},
|
||||
@@ -479,7 +483,7 @@ export function closeSettingsSideMenu() {
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
Navigation.mergeOptions(CHANNEL, {
|
||||
sideMenu: {
|
||||
right: {visible: false},
|
||||
},
|
||||
|
||||
@@ -17,17 +17,23 @@ import Store from '@store/store';
|
||||
import {NavigationTypes} from '@constants';
|
||||
|
||||
jest.unmock('@actions/navigation');
|
||||
jest.mock('@store/ephemeral_store', () => ({
|
||||
getNavigationTopComponentId: jest.fn(),
|
||||
clearNavigationComponents: jest.fn(),
|
||||
addNavigationModal: jest.fn(),
|
||||
hasModalsOpened: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const store = mockStore(intitialState);
|
||||
Store.redux = store;
|
||||
|
||||
// Mock EphemeralStore add/remove modal
|
||||
const add = EphemeralStore.addNavigationModal;
|
||||
const remove = EphemeralStore.removeNavigationModal;
|
||||
EphemeralStore.removeNavigationModal = (componentId) => {
|
||||
remove(componentId);
|
||||
EphemeralStore.removeNavigationComponentId(componentId);
|
||||
};
|
||||
|
||||
EphemeralStore.addNavigationModal = (componentId) => {
|
||||
add(componentId);
|
||||
EphemeralStore.addNavigationComponentId(componentId);
|
||||
};
|
||||
|
||||
describe('@actions/navigation', () => {
|
||||
const topComponentId = 'top-component-id';
|
||||
const name = 'name';
|
||||
@@ -39,7 +45,16 @@ describe('@actions/navigation', () => {
|
||||
const options = {
|
||||
testOption: 'test',
|
||||
};
|
||||
EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
|
||||
|
||||
beforeEach(() => {
|
||||
EphemeralStore.clearNavigationComponents();
|
||||
EphemeralStore.clearNavigationModals();
|
||||
|
||||
// mock that we have a root screen
|
||||
EphemeralStore.addNavigationComponentId(topComponentId);
|
||||
});
|
||||
|
||||
// EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
|
||||
|
||||
test('resetToChannel should call Navigation.setRoot', () => {
|
||||
const setRoot = jest.spyOn(Navigation, 'setRoot');
|
||||
@@ -425,15 +440,20 @@ describe('@actions/navigation', () => {
|
||||
test('dismissModal should call Navigation.dismissModal', async () => {
|
||||
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
|
||||
|
||||
NavigationActions.showModal('First', 'First Modal', passProps, options);
|
||||
|
||||
await NavigationActions.dismissModal(options);
|
||||
expect(dismissModal).toHaveBeenCalledWith(topComponentId, options);
|
||||
expect(dismissModal).toHaveBeenCalledWith('First', options);
|
||||
});
|
||||
|
||||
test('dismissAllModals should call Navigation.dismissAllModals', async () => {
|
||||
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
|
||||
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
|
||||
|
||||
NavigationActions.showModal('First', 'First Modal', passProps, options);
|
||||
NavigationActions.showModal('Second', 'Second Modal', passProps, options);
|
||||
|
||||
await NavigationActions.dismissAllModals(options);
|
||||
expect(dismissAllModals).toHaveBeenCalledWith(options);
|
||||
expect(dismissModal).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('mergeNavigationOptions should call Navigation.mergeOptions', () => {
|
||||
@@ -493,12 +513,15 @@ describe('@actions/navigation', () => {
|
||||
});
|
||||
|
||||
test('dismissAllModalsAndPopToRoot should call Navigation.dismissAllModals, Navigation.popToRoot, and emit event', async () => {
|
||||
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
|
||||
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
|
||||
const popToRoot = jest.spyOn(Navigation, 'popToRoot');
|
||||
EventEmitter.emit = jest.fn();
|
||||
|
||||
NavigationActions.showModal('First', 'First Modal', passProps, options);
|
||||
NavigationActions.showModal('Second', 'Second Modal', passProps, options);
|
||||
|
||||
await NavigationActions.dismissAllModalsAndPopToRoot();
|
||||
expect(dismissAllModals).toHaveBeenCalled();
|
||||
expect(dismissModal).toHaveBeenCalledTimes(2);
|
||||
expect(popToRoot).toHaveBeenCalledWith(topComponentId);
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT);
|
||||
});
|
||||
|
||||
@@ -247,7 +247,7 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler, i
|
||||
// Fallback to API response error, if any.
|
||||
if (teamError) {
|
||||
if (errorHandler) {
|
||||
errorHandler();
|
||||
errorHandler(intl);
|
||||
}
|
||||
return {error: teamError};
|
||||
}
|
||||
|
||||
66
app/actions/views/custom_status.ts
Normal file
66
app/actions/views/custom_status.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Client4} from '@client/rest';
|
||||
import {logError} from '@mm-redux/actions/errors';
|
||||
import {UserTypes} from '@mm-redux/action_types';
|
||||
import {getCurrentUser} from '@mm-redux/selectors/entities/common';
|
||||
import {ActionFunc, DispatchFunc, batchActions, GetStateFunc} from '@mm-redux/types/actions';
|
||||
import {UserCustomStatus} from '@mm-redux/types/users';
|
||||
|
||||
export function setCustomStatus(customStatus: UserCustomStatus): ActionFunc {
|
||||
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
|
||||
const user = getCurrentUser(getState());
|
||||
if (!user.props) {
|
||||
user.props = {};
|
||||
}
|
||||
|
||||
const oldCustomStatus = user.props.customStatus;
|
||||
user.props.customStatus = JSON.stringify(customStatus);
|
||||
dispatch({type: UserTypes.RECEIVED_ME, data: user});
|
||||
|
||||
try {
|
||||
await Client4.updateCustomStatus(customStatus);
|
||||
} catch (error) {
|
||||
user.props.customStatus = oldCustomStatus;
|
||||
dispatch(batchActions([
|
||||
{type: UserTypes.RECEIVED_ME, data: user},
|
||||
logError(error),
|
||||
]));
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function unsetCustomStatus(): ActionFunc {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
try {
|
||||
await Client4.unsetCustomStatus();
|
||||
} catch (error) {
|
||||
dispatch(logError(error));
|
||||
return {error};
|
||||
}
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function removeRecentCustomStatus(customStatus: UserCustomStatus): ActionFunc {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
try {
|
||||
await Client4.removeRecentCustomStatus(customStatus);
|
||||
} catch (error) {
|
||||
dispatch(logError(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
setCustomStatus,
|
||||
unsetCustomStatus,
|
||||
removeRecentCustomStatus,
|
||||
};
|
||||
@@ -4,14 +4,15 @@
|
||||
import {intlShape} from 'react-intl';
|
||||
import {Keyboard} from 'react-native';
|
||||
|
||||
import {showModalOverCurrentContext} from '@actions/navigation';
|
||||
import {dismissAllModals, showModalOverCurrentContext} from '@actions/navigation';
|
||||
import {loadChannelsByTeamName} from '@actions/views/channel';
|
||||
import {selectFocusedPostId} from '@mm-redux/actions/posts';
|
||||
import type {DispatchFunc} from '@mm-redux/types/actions';
|
||||
import {permalinkBadTeam} from '@utils/general';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
export let showingPermalink = false;
|
||||
import type {DispatchFunc} from '@mm-redux/types/actions';
|
||||
|
||||
let showingPermalink = false;
|
||||
|
||||
export function showPermalink(intl: typeof intlShape, teamName: string, postId: string, openAsPermalink = true) {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
@@ -20,27 +21,26 @@ export function showPermalink(intl: typeof intlShape, teamName: string, postId:
|
||||
if (!loadTeam.error) {
|
||||
Keyboard.dismiss();
|
||||
dispatch(selectFocusedPostId(postId));
|
||||
|
||||
if (!showingPermalink) {
|
||||
const screen = 'Permalink';
|
||||
const passProps = {
|
||||
isPermalink: openAsPermalink,
|
||||
onClose: () => {
|
||||
dispatch(closePermalink());
|
||||
},
|
||||
teamName,
|
||||
};
|
||||
|
||||
const options = {
|
||||
layout: {
|
||||
componentBackgroundColor: changeOpacity('#000', 0.2),
|
||||
},
|
||||
};
|
||||
|
||||
showingPermalink = true;
|
||||
showModalOverCurrentContext(screen, passProps, options);
|
||||
if (showingPermalink) {
|
||||
await dismissAllModals();
|
||||
}
|
||||
|
||||
const screen = 'Permalink';
|
||||
const passProps = {
|
||||
isPermalink: openAsPermalink,
|
||||
teamName,
|
||||
};
|
||||
|
||||
const options = {
|
||||
layout: {
|
||||
componentBackgroundColor: changeOpacity('#000', 0.2),
|
||||
},
|
||||
};
|
||||
|
||||
showingPermalink = true;
|
||||
showModalOverCurrentContext(screen, passProps, options);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {loadChannelsForTeam} from '@actions/views/channel';
|
||||
import {loadChannelsForTeam, setChannelRetryFailed} from '@actions/views/channel';
|
||||
import {getPostsSince} from '@actions/views/post';
|
||||
import {loadMe} from '@actions/views/user';
|
||||
import {WebsocketEvents} from '@constants';
|
||||
@@ -135,7 +135,10 @@ export function doReconnect(now: number) {
|
||||
const {lastDisconnectAt} = state.websocket;
|
||||
const actions: Array<GenericAction> = [];
|
||||
|
||||
dispatch(wsConnected(now));
|
||||
dispatch(batchActions([
|
||||
wsConnected(now),
|
||||
setChannelRetryFailed(false),
|
||||
], 'BATCH_WS_SUCCESS'));
|
||||
|
||||
try {
|
||||
const {data: me}: any = await dispatch(loadMe(null, null, true));
|
||||
|
||||
@@ -9,7 +9,7 @@ import {Server, WebSocket as MockWebSocket} from 'mock-socket';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
|
||||
import {GeneralTypes, UserTypes} from '@mm-redux/action_types';
|
||||
import {UserTypes} from '@mm-redux/action_types';
|
||||
import {notVisibleUsersActions} from '@mm-redux/actions/helpers';
|
||||
import {Client4} from '@client/rest';
|
||||
import {General, Posts, RequestStatus} from '@mm-redux/constants';
|
||||
@@ -176,7 +176,7 @@ describe('Actions.Websocket doReconnect', () => {
|
||||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
];
|
||||
const expectedMissingActions = [
|
||||
'BATCH_WS_RECONNECT',
|
||||
@@ -215,7 +215,7 @@ describe('Actions.Websocket doReconnect', () => {
|
||||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
'BATCH_WS_RECONNECT',
|
||||
];
|
||||
const expectedMissingActions = [
|
||||
@@ -260,7 +260,7 @@ describe('Actions.Websocket doReconnect', () => {
|
||||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
];
|
||||
const expectedMissingActions = [
|
||||
'BATCH_WS_RECONNECT',
|
||||
@@ -304,7 +304,7 @@ describe('Actions.Websocket doReconnect', () => {
|
||||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
'BATCH_WS_RECONNECT',
|
||||
];
|
||||
const expectedMissingActions = [
|
||||
@@ -337,7 +337,7 @@ describe('Actions.Websocket doReconnect', () => {
|
||||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
'BATCH_WS_LEAVE_TEAM',
|
||||
'BATCH_WS_RECONNECT',
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import {analytics} from '@init/analytics';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import {UserProfile, UserStatus} from '@mm-redux/types/users';
|
||||
import {UserCustomStatus, UserProfile, UserStatus} from '@mm-redux/types/users';
|
||||
import {buildQueryString, isMinimumServerVersion} from '@mm-redux/utils/helpers';
|
||||
|
||||
import {PER_PAGE_DEFAULT} from './constants';
|
||||
@@ -43,6 +43,9 @@ export interface ClientUsersMix {
|
||||
getStatusesByIds: (userIds: string[]) => Promise<UserStatus[]>;
|
||||
getStatus: (userId: string) => Promise<UserStatus>;
|
||||
updateStatus: (status: UserStatus) => Promise<UserStatus>;
|
||||
updateCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>;
|
||||
unsetCustomStatus: () => Promise<{status: string}>;
|
||||
removeRecentCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>;
|
||||
}
|
||||
|
||||
const ClientUsers = (superclass: any) => class extends superclass {
|
||||
@@ -393,6 +396,27 @@ const ClientUsers = (superclass: any) => class extends superclass {
|
||||
{method: 'put', body: JSON.stringify(status)},
|
||||
);
|
||||
};
|
||||
|
||||
updateCustomStatus = (customStatus: UserCustomStatus) => {
|
||||
return this.doFetch(
|
||||
`${this.getUserRoute('me')}/status/custom`,
|
||||
{method: 'put', body: JSON.stringify(customStatus)},
|
||||
);
|
||||
};
|
||||
|
||||
unsetCustomStatus = () => {
|
||||
return this.doFetch(
|
||||
`${this.getUserRoute('me')}/status/custom`,
|
||||
{method: 'delete'},
|
||||
);
|
||||
};
|
||||
|
||||
removeRecentCustomStatus = (customStatus: UserCustomStatus) => {
|
||||
return this.doFetch(
|
||||
`${this.getUserRoute('me')}/status/custom/recent/delete`,
|
||||
{method: 'post', body: JSON.stringify(customStatus)},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default ClientUsers;
|
||||
|
||||
@@ -16,14 +16,14 @@ import mattermostManaged from 'app/mattermost_managed';
|
||||
export default class AtMention extends React.PureComponent {
|
||||
static propTypes = {
|
||||
isSearchResult: PropTypes.bool,
|
||||
mentionKeys: PropTypes.array.isRequired,
|
||||
mentionKeys: PropTypes.array,
|
||||
mentionName: PropTypes.string.isRequired,
|
||||
mentionStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
onPostPress: PropTypes.func,
|
||||
textStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
teammateNameDisplay: PropTypes.string,
|
||||
theme: PropTypes.object.isRequired,
|
||||
usersByUsername: PropTypes.object.isRequired,
|
||||
usersByUsername: PropTypes.object,
|
||||
groupsByName: PropTypes.object,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getUsersByUsername} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
|
||||
|
||||
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
|
||||
import {getAllGroupsForReferenceByName} from '@mm-redux/selectors/entities/groups';
|
||||
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
|
||||
import {getUsersByUsername} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import AtMention from './at_mention';
|
||||
|
||||
|
||||
@@ -2,25 +2,21 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Permissions from 'react-native-permissions';
|
||||
import {Alert, StatusBar} from 'react-native';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
import {VALID_MIME_TYPES} from 'app/screens/edit_profile/edit_profile';
|
||||
import {VALID_MIME_TYPES} from '@screens/edit_profile/edit_profile';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import AttachmentButton from './index';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchCamera: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
|
||||
launchImageLibrary: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
|
||||
}));
|
||||
|
||||
describe('AttachmentButton', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
theme: Preferences.THEMES.default,
|
||||
maxFileSize: 10,
|
||||
@@ -28,7 +24,7 @@ describe('AttachmentButton', () => {
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<AttachmentButton {...baseProps}/>);
|
||||
const wrapper = shallowWithIntl(<AttachmentButton {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -40,7 +36,7 @@ describe('AttachmentButton', () => {
|
||||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(<AttachmentButton {...props}/>);
|
||||
const wrapper = shallowWithIntl(<AttachmentButton {...props}/>);
|
||||
|
||||
const file = {
|
||||
type: 'image/gif',
|
||||
@@ -59,7 +55,7 @@ describe('AttachmentButton', () => {
|
||||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(<AttachmentButton {...props}/>);
|
||||
const wrapper = shallowWithIntl(<AttachmentButton {...props}/>);
|
||||
|
||||
const file = {
|
||||
fileSize: 10,
|
||||
@@ -77,9 +73,8 @@ describe('AttachmentButton', () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
|
||||
@@ -93,9 +88,8 @@ describe('AttachmentButton', () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
|
||||
@@ -106,9 +100,8 @@ describe('AttachmentButton', () => {
|
||||
});
|
||||
|
||||
test('should re-enable StatusBar after ImagePicker launchCamera finishes', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
@@ -120,9 +113,8 @@ describe('AttachmentButton', () => {
|
||||
});
|
||||
|
||||
test('should re-enable StatusBar after ImagePicker launchImageLibrary finishes', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
|
||||
@@ -6,8 +6,8 @@ import {shallow} from 'enzyme';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import {Command, AutocompleteSuggestion} from '@mm-redux/types/integrations';
|
||||
|
||||
import Store from '@store/store';
|
||||
import {intl} from 'test/intl-test-helper';
|
||||
|
||||
import {
|
||||
thunk,
|
||||
@@ -86,7 +86,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
|
||||
const dataSource: AutocompleteSuggestion[] = [
|
||||
{
|
||||
@@ -117,7 +120,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
||||
commands: [command],
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/the'});
|
||||
|
||||
expect(wrapper.state('dataSource')).toEqual([
|
||||
@@ -137,7 +143,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
||||
commands: [],
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/ji'});
|
||||
|
||||
expect(wrapper.state('dataSource')).toEqual([
|
||||
@@ -156,7 +165,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
|
||||
wrapper.setProps({value: '/'});
|
||||
expect(wrapper.state('dataSource')).toEqual([
|
||||
@@ -207,7 +219,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/jira i', suggestions: []});
|
||||
|
||||
const expected: AutocompleteSuggestion[] = [
|
||||
@@ -232,7 +247,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
||||
appsEnabled: false,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/', suggestions: []});
|
||||
|
||||
expect(wrapper.state('dataSource')).toEqual([
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
import {Text} from 'react-native';
|
||||
|
||||
import {alertErrorWithFallback} from '@utils/general';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import ChannelLink from './channel_link';
|
||||
|
||||
jest.mock('react-intl');
|
||||
|
||||
jest.mock('@utils/general', () => {
|
||||
const general = jest.requireActual('../../utils/general');
|
||||
return {
|
||||
@@ -20,7 +18,6 @@ jest.mock('@utils/general', () => {
|
||||
});
|
||||
|
||||
describe('ChannelLink', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const channelsByName = {
|
||||
firstChannel: {id: 'channel_id_1', name: 'firstChannel', display_name: 'First Channel', team_id: 'current_team_id'},
|
||||
secondChannel: {id: 'channel_id_2', name: 'secondChannel', display_name: 'Second Channel', team_id: 'current_team_id'},
|
||||
@@ -40,9 +37,8 @@ describe('ChannelLink', () => {
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ChannelLink {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
@@ -74,9 +70,8 @@ describe('ChannelLink', () => {
|
||||
});
|
||||
|
||||
test('should call props.actions and onChannelLinkPress on handlePress', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ChannelLink {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const channel = channelsByName.firstChannel;
|
||||
@@ -106,16 +101,17 @@ describe('ChannelLink', () => {
|
||||
channelName: newChannelName,
|
||||
actions: {...baseProps.actions, joinChannel},
|
||||
};
|
||||
const intl = {formatMessage};
|
||||
|
||||
const joinFailedMessage = {
|
||||
id: 'mobile.join_channel.error',
|
||||
defaultMessage: 'We couldn\'t join the channel {displayName}. Please check your connection and try again.',
|
||||
};
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ChannelLink {...newProps}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
|
||||
const {intl} = wrapper.context();
|
||||
|
||||
await wrapper.instance().handlePress();
|
||||
expect(newProps.actions.joinChannel).toHaveBeenCalledTimes(1);
|
||||
expect(newProps.actions.joinChannel).toBeCalledWith('current_user_id', 'current_team_id', null, newChannelName);
|
||||
|
||||
@@ -66,7 +66,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
|
||||
size="small"
|
||||
/>
|
||||
</View>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Still trying to load your content..."
|
||||
id="mobile.channel_loader.still_loading"
|
||||
style={
|
||||
|
||||
@@ -1,393 +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 {Posts} from '@mm-redux/constants';
|
||||
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
||||
import Markdown from 'app/components/markdown';
|
||||
|
||||
import LastUsers from './last_users';
|
||||
|
||||
const {
|
||||
JOIN_CHANNEL, ADD_TO_CHANNEL, REMOVE_FROM_CHANNEL, LEAVE_CHANNEL,
|
||||
JOIN_TEAM, ADD_TO_TEAM, REMOVE_FROM_TEAM, LEAVE_TEAM,
|
||||
} = Posts.POST_TYPES;
|
||||
|
||||
const postTypeMessage = {
|
||||
[JOIN_CHANNEL]: {
|
||||
one: {
|
||||
id: t('combined_system_message.joined_channel.one'),
|
||||
defaultMessage: '{firstUser} **joined the channel**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.joined_channel.one_you'),
|
||||
defaultMessage: 'You **joined the channel**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.joined_channel.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **joined the channel**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.joined_channel.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} **joined the channel**.',
|
||||
},
|
||||
},
|
||||
[ADD_TO_CHANNEL]: {
|
||||
one: {
|
||||
id: t('combined_system_message.added_to_channel.one'),
|
||||
defaultMessage: '{firstUser} **added to the channel** by {actor}.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.added_to_channel.one_you'),
|
||||
defaultMessage: 'You were **added to the channel** by {actor}.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.added_to_channel.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **added to the channel** by {actor}.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.added_to_channel.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} were **added to the channel** by {actor}.',
|
||||
},
|
||||
},
|
||||
[REMOVE_FROM_CHANNEL]: {
|
||||
one: {
|
||||
id: t('combined_system_message.removed_from_channel.one'),
|
||||
defaultMessage: '{firstUser} was **removed from the channel**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.removed_from_channel.one_you'),
|
||||
defaultMessage: 'You were **removed from the channel**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.removed_from_channel.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} were **removed from the channel**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.removed_from_channel.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} were **removed from the channel**.',
|
||||
},
|
||||
},
|
||||
[LEAVE_CHANNEL]: {
|
||||
one: {
|
||||
id: t('combined_system_message.left_channel.one'),
|
||||
defaultMessage: '{firstUser} **left the channel**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.left_channel.one_you'),
|
||||
defaultMessage: 'You **left the channel**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.left_channel.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **left the channel**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.left_channel.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} **left the channel**.',
|
||||
},
|
||||
},
|
||||
[JOIN_TEAM]: {
|
||||
one: {
|
||||
id: t('combined_system_message.joined_team.one'),
|
||||
defaultMessage: '{firstUser} **joined the team**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.joined_team.one_you'),
|
||||
defaultMessage: 'You **joined the team**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.joined_team.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **joined the team**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.joined_team.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} **joined the team**.',
|
||||
},
|
||||
},
|
||||
[ADD_TO_TEAM]: {
|
||||
one: {
|
||||
id: t('combined_system_message.added_to_team.one'),
|
||||
defaultMessage: '{firstUser} **added to the team** by {actor}.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.added_to_team.one_you'),
|
||||
defaultMessage: 'You were **added to the team** by {actor}.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.added_to_team.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **added to the team** by {actor}.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.added_to_team.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} were **added to the team** by {actor}.',
|
||||
},
|
||||
},
|
||||
[REMOVE_FROM_TEAM]: {
|
||||
one: {
|
||||
id: t('combined_system_message.removed_from_team.one'),
|
||||
defaultMessage: '{firstUser} was **removed from the team**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.removed_from_team.one_you'),
|
||||
defaultMessage: 'You were **removed from the team**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.removed_from_team.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} were **removed from the team**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.removed_from_team.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} were **removed from the team**.',
|
||||
},
|
||||
},
|
||||
[LEAVE_TEAM]: {
|
||||
one: {
|
||||
id: t('combined_system_message.left_team.one'),
|
||||
defaultMessage: '{firstUser} **left the team**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.left_team.one_you'),
|
||||
defaultMessage: 'You **left the team**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.left_team.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **left the team**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.left_team.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} **left the team**.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default class CombinedSystemMessage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
getMissingProfilesByIds: PropTypes.func.isRequired,
|
||||
getMissingProfilesByUsernames: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
allUserIds: PropTypes.array.isRequired,
|
||||
allUsernames: PropTypes.array.isRequired,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
currentUsername: PropTypes.string.isRequired,
|
||||
messageData: PropTypes.array.isRequired,
|
||||
showJoinLeave: PropTypes.bool.isRequired,
|
||||
textStyles: PropTypes.object,
|
||||
theme: PropTypes.object.isRequired,
|
||||
userProfiles: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
allUserIds: [],
|
||||
allUsernames: [],
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.loadUserProfiles(this.props.allUserIds, this.props.allUsernames);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {allUserIds, allUsernames} = this.props;
|
||||
if (allUserIds !== prevProps.allUserIds || allUsernames !== prevProps.allUsernames) {
|
||||
this.loadUserProfiles(allUserIds, allUsernames);
|
||||
}
|
||||
}
|
||||
|
||||
loadUserProfiles = (allUserIds, allUsernames) => {
|
||||
if (allUserIds.length > 0) {
|
||||
this.props.actions.getMissingProfilesByIds(allUserIds);
|
||||
}
|
||||
|
||||
if (allUsernames.length > 0) {
|
||||
this.props.actions.getMissingProfilesByUsernames(allUsernames);
|
||||
}
|
||||
}
|
||||
|
||||
getAllUsernames = () => {
|
||||
const {
|
||||
allUserIds,
|
||||
allUsernames,
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
userProfiles,
|
||||
} = this.props;
|
||||
const {formatMessage} = this.context.intl;
|
||||
const usernames = userProfiles.reduce((acc, user) => {
|
||||
acc[user.id] = user.username;
|
||||
acc[user.username] = user.username;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const currentUserDisplayName = formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'});
|
||||
if (allUserIds.includes(currentUserId)) {
|
||||
usernames[currentUserId] = currentUserDisplayName;
|
||||
} else if (allUsernames.includes(currentUsername)) {
|
||||
usernames[currentUsername] = currentUserDisplayName;
|
||||
}
|
||||
|
||||
return usernames;
|
||||
}
|
||||
|
||||
getUsernamesByIds = (userIds = []) => {
|
||||
const {currentUserId, currentUsername} = this.props;
|
||||
const allUsernames = this.getAllUsernames();
|
||||
|
||||
const {formatMessage} = this.context.intl;
|
||||
const someone = formatMessage({id: t('channel_loader.someone'), defaultMessage: 'Someone'});
|
||||
|
||||
const usernames = userIds.
|
||||
filter((userId) => {
|
||||
return userId !== currentUserId && userId !== currentUsername;
|
||||
}).
|
||||
map((userId) => {
|
||||
return allUsernames[userId] ? `@${allUsernames[userId]}` : someone;
|
||||
}).filter((username) => {
|
||||
return username && username !== '';
|
||||
});
|
||||
|
||||
if (userIds.includes(currentUserId)) {
|
||||
usernames.unshift(allUsernames[currentUserId]);
|
||||
} else if (userIds.includes(currentUsername)) {
|
||||
usernames.unshift(allUsernames[currentUsername]);
|
||||
}
|
||||
|
||||
return usernames;
|
||||
}
|
||||
|
||||
renderFormattedMessage(postType, userIds, actorId, style) {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
textStyles,
|
||||
theme,
|
||||
} = this.props;
|
||||
const usernames = this.getUsernamesByIds(userIds);
|
||||
let actor = actorId ? this.getUsernamesByIds([actorId])[0] : '';
|
||||
if (actor && (actorId === currentUserId || actorId === currentUsername)) {
|
||||
actor = actor.toLowerCase();
|
||||
}
|
||||
|
||||
const firstUser = usernames[0];
|
||||
const secondUser = usernames[1];
|
||||
const numOthers = usernames.length - 1;
|
||||
|
||||
if (numOthers > 1) {
|
||||
return (
|
||||
<LastUsers
|
||||
actor={actor}
|
||||
expandedLocale={postTypeMessage[postType].many_expanded}
|
||||
postType={postType}
|
||||
style={style}
|
||||
textStyles={textStyles}
|
||||
theme={theme}
|
||||
usernames={usernames}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let localeHolder;
|
||||
if (numOthers === 0) {
|
||||
localeHolder = postTypeMessage[postType].one;
|
||||
|
||||
if (
|
||||
(userIds[0] === currentUserId || userIds[0] === currentUsername) &&
|
||||
postTypeMessage[postType].one_you
|
||||
) {
|
||||
localeHolder = postTypeMessage[postType].one_you;
|
||||
}
|
||||
} else if (numOthers === 1) {
|
||||
localeHolder = postTypeMessage[postType].two;
|
||||
}
|
||||
|
||||
const formattedMessage = formatMessage(localeHolder, {firstUser, secondUser, actor});
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
baseTextStyle={style.baseText}
|
||||
textStyles={textStyles}
|
||||
value={formattedMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderMessage(postType, userIds, actorId, style) {
|
||||
return (
|
||||
<React.Fragment key={postType + actorId}>
|
||||
{this.renderFormattedMessage(postType, userIds, actorId, {baseText: style.baseText, linkText: style.linkText})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentUserId,
|
||||
messageData,
|
||||
theme,
|
||||
} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const content = [];
|
||||
const removedUserIds = [];
|
||||
for (const message of messageData) {
|
||||
const {
|
||||
postType,
|
||||
actorId,
|
||||
} = message;
|
||||
let userIds = message.userIds;
|
||||
|
||||
if (!this.props.showJoinLeave && actorId !== currentUserId) {
|
||||
const affectsCurrentUser = userIds.indexOf(currentUserId) !== -1;
|
||||
|
||||
if (affectsCurrentUser) {
|
||||
// Only show the message that the current user was added, etc
|
||||
userIds = [currentUserId];
|
||||
} else {
|
||||
// Not something the current user did or was affected by
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (postType === REMOVE_FROM_CHANNEL) {
|
||||
removedUserIds.push(...userIds);
|
||||
continue;
|
||||
}
|
||||
|
||||
content.push(this.renderMessage(postType, userIds, actorId, style));
|
||||
}
|
||||
|
||||
if (removedUserIds.length > 0) {
|
||||
const uniqueRemovedUserIds = removedUserIds.filter((id, index, arr) => arr.indexOf(id) === index);
|
||||
content.push(this.renderMessage(REMOVE_FROM_CHANNEL, uniqueRemovedUserIds, currentUserId, style));
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{content}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
baseText: {
|
||||
color: theme.centerChannelColor,
|
||||
opacity: 0.6,
|
||||
},
|
||||
linkText: {
|
||||
color: theme.linkColor,
|
||||
opacity: 0.8,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,38 +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 {getMissingProfilesByIds, getMissingProfilesByUsernames} from '@mm-redux/actions/users';
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
import {getBool} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser, makeGetProfilesByIdsAndUsernames} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import CombinedSystemMessage from './combined_system_message';
|
||||
|
||||
function makeMapStateToProps() {
|
||||
const getProfilesByIdsAndUsernames = makeGetProfilesByIdsAndUsernames();
|
||||
|
||||
return (state, ownProps) => {
|
||||
const currentUser = getCurrentUser(state) || {};
|
||||
const {allUserIds, allUsernames} = ownProps;
|
||||
return {
|
||||
currentUserId: currentUser.id,
|
||||
currentUsername: currentUser.username,
|
||||
showJoinLeave: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true),
|
||||
userProfiles: getProfilesByIdsAndUsernames(state, {allUserIds, allUsernames}),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
getMissingProfilesByIds,
|
||||
getMissingProfilesByUsernames,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(CombinedSystemMessage);
|
||||
@@ -1,168 +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 {Text} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
|
||||
import FormattedMarkdownText from 'app/components/formatted_markdown_text';
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import Markdown from 'app/components/markdown';
|
||||
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
||||
const typeMessage = {
|
||||
[Posts.POST_TYPES.ADD_TO_CHANNEL]: {
|
||||
id: t('last_users_message.added_to_channel.type'),
|
||||
defaultMessage: 'were **added to the channel** by {actor}.',
|
||||
},
|
||||
[Posts.POST_TYPES.JOIN_CHANNEL]: {
|
||||
id: t('last_users_message.joined_channel.type'),
|
||||
defaultMessage: '**joined the channel**.',
|
||||
},
|
||||
[Posts.POST_TYPES.LEAVE_CHANNEL]: {
|
||||
id: t('last_users_message.left_channel.type'),
|
||||
defaultMessage: '**left the channel**.',
|
||||
},
|
||||
[Posts.POST_TYPES.REMOVE_FROM_CHANNEL]: {
|
||||
id: t('last_users_message.removed_from_channel.type'),
|
||||
defaultMessage: 'were **removed from the channel**.',
|
||||
},
|
||||
[Posts.POST_TYPES.ADD_TO_TEAM]: {
|
||||
id: t('last_users_message.added_to_team.type'),
|
||||
defaultMessage: 'were **added to the team** by {actor}.',
|
||||
},
|
||||
[Posts.POST_TYPES.JOIN_TEAM]: {
|
||||
id: t('last_users_message.joined_team.type'),
|
||||
defaultMessage: '**joined the team**.',
|
||||
},
|
||||
[Posts.POST_TYPES.LEAVE_TEAM]: {
|
||||
id: t('last_users_message.left_team.type'),
|
||||
defaultMessage: '**left the team**.',
|
||||
},
|
||||
[Posts.POST_TYPES.REMOVE_FROM_TEAM]: {
|
||||
id: t('last_users_message.removed_from_team.type'),
|
||||
defaultMessage: 'were **removed from the team**.',
|
||||
},
|
||||
};
|
||||
|
||||
export default class LastUsers extends React.PureComponent {
|
||||
static propTypes = {
|
||||
actor: PropTypes.string,
|
||||
expandedLocale: PropTypes.object.isRequired,
|
||||
postType: PropTypes.string.isRequired,
|
||||
style: PropTypes.object.isRequired,
|
||||
textStyles: PropTypes.object,
|
||||
theme: PropTypes.object.isRequired,
|
||||
usernames: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
usernames: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
expand: false,
|
||||
};
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
handleOnPress = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
this.setState({expand: true});
|
||||
}
|
||||
|
||||
renderExpandedView = () => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {
|
||||
actor,
|
||||
expandedLocale,
|
||||
style,
|
||||
textStyles,
|
||||
usernames,
|
||||
} = this.props;
|
||||
|
||||
const lastIndex = usernames.length - 1;
|
||||
const lastUser = usernames[lastIndex];
|
||||
|
||||
const formattedMessage = formatMessage(expandedLocale, {
|
||||
users: usernames.slice(0, lastIndex).join(', '),
|
||||
lastUser,
|
||||
actor,
|
||||
});
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
baseTextStyle={style.baseText}
|
||||
textStyles={textStyles}
|
||||
value={formattedMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderCollapsedView = () => {
|
||||
const {
|
||||
actor,
|
||||
postType,
|
||||
style,
|
||||
textStyles,
|
||||
theme,
|
||||
usernames,
|
||||
} = this.props;
|
||||
|
||||
const firstUser = usernames[0];
|
||||
const numOthers = usernames.length - 1;
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<FormattedMarkdownText
|
||||
id={'last_users_message.first'}
|
||||
defaultMessage={'{firstUser} and '}
|
||||
values={{firstUser}}
|
||||
baseTextStyle={style.baseText}
|
||||
style={style.baseText}
|
||||
textStyles={textStyles}
|
||||
theme={theme}
|
||||
/>
|
||||
<Text>{' '}</Text>
|
||||
<Text
|
||||
style={style.linkText}
|
||||
onPress={this.handleOnPress}
|
||||
>
|
||||
<FormattedText
|
||||
id={'last_users_message.others'}
|
||||
defaultMessage={'{numOthers} others '}
|
||||
values={{numOthers}}
|
||||
/>
|
||||
</Text>
|
||||
<FormattedMarkdownText
|
||||
id={typeMessage[postType].id}
|
||||
defaultMessage={typeMessage[postType].defaultMessage}
|
||||
values={{actor}}
|
||||
baseTextStyle={style.baseText}
|
||||
style={style.baseText}
|
||||
textStyles={textStyles}
|
||||
theme={theme}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.expand) {
|
||||
return this.renderExpandedView();
|
||||
}
|
||||
|
||||
return this.renderCollapsedView();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {makeGenerateCombinedPost} from '@mm-redux/utils/post_list';
|
||||
|
||||
import Post from 'app/components/post';
|
||||
|
||||
export function makeMapStateToProps() {
|
||||
const generateCombinedPost = makeGenerateCombinedPost();
|
||||
|
||||
return (state, ownProps) => {
|
||||
return {
|
||||
post: generateCombinedPost(state, ownProps.combinedId),
|
||||
postId: ownProps.combinedId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Note that this also passes through Post's mapStateToProps
|
||||
export default connect(makeMapStateToProps)(Post);
|
||||
@@ -198,7 +198,9 @@ exports[`UserListRow should match snapshot for currentUser with (you) populated
|
||||
}
|
||||
}
|
||||
testID="custom_list.user_item.display_username"
|
||||
/>
|
||||
>
|
||||
@user - you
|
||||
</Text>
|
||||
<BotTag
|
||||
show={false}
|
||||
theme={
|
||||
@@ -410,7 +412,9 @@ exports[`UserListRow should match snapshot for deactivated user 1`] = `
|
||||
"marginTop": 2,
|
||||
}
|
||||
}
|
||||
/>
|
||||
>
|
||||
Deactivated
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CustomListRow>
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import UserListRow from './user_list_row';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('@utils/theme', () => {
|
||||
const original = jest.requireActual('../../../utils/theme');
|
||||
return {
|
||||
@@ -18,7 +17,6 @@ jest.mock('@utils/theme', () => {
|
||||
});
|
||||
|
||||
describe('UserListRow', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
id: '123455',
|
||||
isMyUser: false,
|
||||
@@ -33,9 +31,8 @@ describe('UserListRow', () => {
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -52,9 +49,8 @@ describe('UserListRow', () => {
|
||||
user: deactivatedUser,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...newProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -68,9 +64,8 @@ describe('UserListRow', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...newProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -84,9 +79,8 @@ describe('UserListRow', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...newProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -97,9 +91,8 @@ describe('UserListRow', () => {
|
||||
isMyUser: true,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...newProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/custom_status/clear_button should match snapshot 1`] = `
|
||||
<ForwardRef
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"justifyContent": "center",
|
||||
},
|
||||
Object {
|
||||
"height": 40,
|
||||
"width": 40,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<CompassIcon
|
||||
name="close-circle"
|
||||
size={20}
|
||||
style={
|
||||
Object {
|
||||
"borderRadius": 1000,
|
||||
"color": "rgba(61,60,64,0.52)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
`;
|
||||
@@ -0,0 +1,39 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/custom_status/custom_status_emoji should match snapshot 1`] = `
|
||||
<Text
|
||||
testID="custom_status_emoji.calendar"
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
undefined,
|
||||
Object {
|
||||
"fontSize": 16,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
📆
|
||||
</Text>
|
||||
</Text>
|
||||
`;
|
||||
|
||||
exports[`components/custom_status/custom_status_emoji should match snapshot with props 1`] = `
|
||||
<Text
|
||||
testID="custom_status_emoji.calendar"
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
undefined,
|
||||
Object {
|
||||
"fontSize": 34,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
📆
|
||||
</Text>
|
||||
</Text>
|
||||
`;
|
||||
@@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/custom_status/custom_status_text should match snapshot 1`] = `
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.5)",
|
||||
"fontSize": 17,
|
||||
"includeFontPadding": false,
|
||||
"textAlignVertical": "center",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
In a meeting
|
||||
</Text>
|
||||
`;
|
||||
|
||||
exports[`components/custom_status/custom_status_text should match snapshot with empty text 1`] = `
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.5)",
|
||||
"fontSize": 17,
|
||||
"includeFontPadding": false,
|
||||
"textAlignVertical": "center",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
|
||||
</Text>
|
||||
`;
|
||||
36
app/components/custom_status/clear_button.test.tsx
Normal file
36
app/components/custom_status/clear_button.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {TouchableOpacity} from 'react-native';
|
||||
|
||||
import ClearButton from '@components/custom_status/clear_button';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
describe('components/custom_status/clear_button', () => {
|
||||
const baseProps = {
|
||||
theme: Preferences.THEMES.default,
|
||||
handlePress: jest.fn(),
|
||||
};
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<ClearButton
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should call handlePress when press event is fired', () => {
|
||||
const wrapper = shallow(
|
||||
<ClearButton
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
wrapper.find(TouchableOpacity).simulate('press');
|
||||
expect(baseProps.handlePress).toBeCalled();
|
||||
});
|
||||
});
|
||||
59
app/components/custom_status/clear_button.tsx
Normal file
59
app/components/custom_status/clear_button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {TouchableOpacity} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
interface Props {
|
||||
handlePress: () => void;
|
||||
size?: number;
|
||||
containerSize?: number;
|
||||
theme: Theme;
|
||||
testID?: string;
|
||||
iconName: string,
|
||||
}
|
||||
|
||||
const ClearButton = ({handlePress, iconName, size, containerSize, theme, testID}: Props) => {
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={preventDoubleTap(handlePress)}
|
||||
style={[style.container, {height: containerSize, width: containerSize}]}
|
||||
testID={testID}
|
||||
>
|
||||
<CompassIcon
|
||||
name={iconName}
|
||||
size={size}
|
||||
style={style.button}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
ClearButton.defaultProps = {
|
||||
size: 20,
|
||||
containerSize: 40,
|
||||
iconName: 'close-circle',
|
||||
};
|
||||
|
||||
export default ClearButton;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
button: {
|
||||
borderRadius: 1000,
|
||||
color: changeOpacity(theme.centerChannelColor, 0.52),
|
||||
},
|
||||
};
|
||||
});
|
||||
45
app/components/custom_status/custom_status_emoji.test.tsx
Normal file
45
app/components/custom_status/custom_status_emoji.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
|
||||
import * as CustomStatusSelectors from '@selectors/custom_status';
|
||||
import {renderWithRedux} from 'test/testing_library';
|
||||
|
||||
jest.mock('@selectors/custom_status');
|
||||
|
||||
describe('components/custom_status/custom_status_emoji', () => {
|
||||
const getCustomStatus = () => {
|
||||
return {
|
||||
emoji: 'calendar',
|
||||
text: 'In a meeting',
|
||||
};
|
||||
};
|
||||
(CustomStatusSelectors.makeGetCustomStatus as jest.Mock).mockReturnValue(getCustomStatus);
|
||||
it('should match snapshot', () => {
|
||||
const wrapper = renderWithRedux(
|
||||
<CustomStatusEmoji/>,
|
||||
);
|
||||
expect(wrapper.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match snapshot with props', () => {
|
||||
const wrapper = renderWithRedux(
|
||||
<CustomStatusEmoji
|
||||
emojiSize={34}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should not render when getCustomStatus returns null', () => {
|
||||
(CustomStatusSelectors.makeGetCustomStatus as jest.Mock).mockReturnValue(() => null);
|
||||
const wrapper = renderWithRedux(
|
||||
<CustomStatusEmoji/>,
|
||||
);
|
||||
|
||||
expect(wrapper.toJSON()).toBeNull();
|
||||
});
|
||||
});
|
||||
46
app/components/custom_status/custom_status_emoji.tsx
Normal file
46
app/components/custom_status/custom_status_emoji.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Text, TextStyle} from 'react-native';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import Emoji from '@components/emoji';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {makeGetCustomStatus} from '@selectors/custom_status';
|
||||
|
||||
interface ComponentProps {
|
||||
emojiSize?: number;
|
||||
userID?: string;
|
||||
style?: TextStyle;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const CustomStatusEmoji = ({emojiSize, userID, style, testID}: ComponentProps) => {
|
||||
const getCustomStatus = makeGetCustomStatus();
|
||||
const customStatus = useSelector((state: GlobalState) => {
|
||||
return getCustomStatus(state, userID);
|
||||
});
|
||||
if (!customStatus?.emoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testIdPrefix = testID ? `${testID}.` : '';
|
||||
return (
|
||||
<Text
|
||||
style={style}
|
||||
testID={`${testIdPrefix}custom_status_emoji.${customStatus.emoji}`}
|
||||
>
|
||||
<Emoji
|
||||
size={emojiSize}
|
||||
emojiName={customStatus.emoji}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
CustomStatusEmoji.defaultProps = {
|
||||
emojiSize: 16,
|
||||
};
|
||||
|
||||
export default CustomStatusEmoji;
|
||||
35
app/components/custom_status/custom_status_text.test.tsx
Normal file
35
app/components/custom_status/custom_status_text.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import CustomStatusText from '@components/custom_status/custom_status_text';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
describe('components/custom_status/custom_status_text', () => {
|
||||
const baseProps = {
|
||||
text: 'In a meeting',
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<CustomStatusText
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match snapshot with empty text', () => {
|
||||
const wrapper = shallow(
|
||||
<CustomStatusText
|
||||
{...baseProps}
|
||||
text={''}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
40
app/components/custom_status/custom_status_text.tsx
Normal file
40
app/components/custom_status/custom_status_text.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Text, TextStyle} from 'react-native';
|
||||
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import type {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
interface ComponentProps {
|
||||
text: string | typeof FormattedText;
|
||||
theme: Theme;
|
||||
textStyle?: TextStyle;
|
||||
ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
|
||||
numberOfLines?: number;
|
||||
}
|
||||
|
||||
const CustomStatusText = ({text, theme, textStyle, ellipsizeMode, numberOfLines}: ComponentProps) => (
|
||||
<Text
|
||||
style={[getStyleSheet(theme).label, textStyle]}
|
||||
ellipsizeMode={ellipsizeMode}
|
||||
numberOfLines={numberOfLines}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
|
||||
export default CustomStatusText;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
label: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
fontSize: 17,
|
||||
textAlignVertical: 'center',
|
||||
includeFontPadding: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -46,7 +46,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
>
|
||||
<View>
|
||||
<View>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Name"
|
||||
id="channel_modal.name"
|
||||
style={
|
||||
@@ -103,7 +103,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Purpose"
|
||||
id="channel_modal.purpose"
|
||||
style={
|
||||
@@ -114,7 +114,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="(optional)"
|
||||
id="channel_modal.optional"
|
||||
style={
|
||||
@@ -169,7 +169,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Describe how this channel should be used."
|
||||
id="channel_modal.descriptionHelp"
|
||||
style={
|
||||
@@ -192,7 +192,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Header"
|
||||
id="channel_modal.header"
|
||||
style={
|
||||
@@ -203,7 +203,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="(optional)"
|
||||
id="channel_modal.optional"
|
||||
style={
|
||||
@@ -265,7 +265,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Set text that will appear in the header of the channel beside the channel name. For example, include frequently used links by typing [Link Title](http://example.com)."
|
||||
id="channel_modal.headerHelp"
|
||||
style={
|
||||
|
||||
@@ -15,7 +15,7 @@ import {General} from '@mm-redux/constants';
|
||||
|
||||
import Autocomplete from 'app/components/autocomplete';
|
||||
import ErrorText from 'app/components/error_text';
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import Loading from 'app/components/loading';
|
||||
import StatusBar from 'app/components/status_bar';
|
||||
import TextInputWithLocalizedPlaceholder from 'app/components/text_input_with_localized_placeholder';
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import Button from 'react-native-button';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {preventDoubleTap} from 'app/utils/tap';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
|
||||
import {getStatusColors} from '@utils/message_attachment_colors';
|
||||
import ButtonBindingText from './button_binding_text';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {ActionResult} from '@mm-redux/types/actions';
|
||||
import {AppBinding} from '@mm-redux/types/apps';
|
||||
import {Post} from '@mm-redux/types/posts';
|
||||
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
|
||||
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
|
||||
import {createCallContext, createCallRequest} from '@utils/apps';
|
||||
import {Channel} from '@mm-redux/types/channels';
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
doAppCall: DoAppCall;
|
||||
getChannel: (channelId: string) => Promise<ActionResult>;
|
||||
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
|
||||
};
|
||||
post: Post;
|
||||
binding: AppBinding;
|
||||
theme: Theme;
|
||||
currentTeamID: string;
|
||||
}
|
||||
export default class ButtonBinding extends PureComponent<Props> {
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
private mounted = false;
|
||||
|
||||
handleActionPress = preventDoubleTap(async () => {
|
||||
const {
|
||||
binding,
|
||||
post,
|
||||
currentTeamID,
|
||||
} = this.props;
|
||||
const intl = this.context.intl;
|
||||
if (!binding.call) {
|
||||
return;
|
||||
}
|
||||
|
||||
let teamID = '';
|
||||
const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any};
|
||||
if (data) {
|
||||
const channel = data as Channel;
|
||||
teamID = channel.team_id;
|
||||
}
|
||||
|
||||
const context = createCallContext(
|
||||
binding.app_id,
|
||||
AppBindingLocations.IN_POST + binding.location,
|
||||
post.channel_id,
|
||||
teamID || currentTeamID,
|
||||
post.id,
|
||||
);
|
||||
const call = createCallRequest(
|
||||
binding.call,
|
||||
context,
|
||||
{post: AppExpandLevels.EXPAND_ALL},
|
||||
);
|
||||
this.setState({executing: true});
|
||||
const res = await this.props.actions.doAppCall(call, AppCallTypes.SUBMIT, this.context.intl);
|
||||
if (this.mounted) {
|
||||
this.setState({executing: false});
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
const errorResponse = res.error;
|
||||
const errorMessage = errorResponse.error || intl.formatMessage(
|
||||
{id: 'apps.error.unknown',
|
||||
defaultMessage: 'Unknown error occurred.',
|
||||
});
|
||||
this.props.actions.postEphemeralCallResponseForPost(errorResponse, errorMessage, post);
|
||||
return;
|
||||
}
|
||||
|
||||
const callResp = res.data!;
|
||||
|
||||
switch (callResp.type) {
|
||||
case AppCallResponseTypes.OK:
|
||||
if (callResp.markdown) {
|
||||
this.props.actions.postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
|
||||
}
|
||||
return;
|
||||
case AppCallResponseTypes.NAVIGATE:
|
||||
case AppCallResponseTypes.FORM:
|
||||
return;
|
||||
default: {
|
||||
const errorMessage = intl.formatMessage(
|
||||
{
|
||||
id: 'apps.error.responses.unknown_type',
|
||||
defaultMessage: 'App response type not supported. Response type: {type}.',
|
||||
},
|
||||
{
|
||||
type: callResp.type,
|
||||
},
|
||||
);
|
||||
this.props.actions.postEphemeralCallResponseForPost(callResp, errorMessage, post);
|
||||
}
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {theme, binding} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<Button
|
||||
containerStyle={[style.button]}
|
||||
disabledContainerStyle={style.buttonDisabled}
|
||||
onPress={this.handleActionPress}
|
||||
>
|
||||
<ButtonBindingText
|
||||
message={binding.label}
|
||||
style={style.text}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
const STATUS_COLORS = getStatusColors(theme);
|
||||
return {
|
||||
button: {
|
||||
borderRadius: 4,
|
||||
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
|
||||
borderWidth: 2,
|
||||
opacity: 1,
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
justifyContent: 'center',
|
||||
height: 36,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
|
||||
},
|
||||
text: {
|
||||
color: STATUS_COLORS.default,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
lineHeight: 17,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
|
||||
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
|
||||
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
|
||||
import {getPost} from '@mm-redux/selectors/entities/posts';
|
||||
|
||||
import ButtonBinding from './button_binding';
|
||||
import {getChannel} from '@mm-redux/actions/channels';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
|
||||
type OwnProps = {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
return {
|
||||
theme: getTheme(state),
|
||||
post: getPost(state, ownProps.postId),
|
||||
currentTeamID: getCurrentTeamId(state),
|
||||
};
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
doAppCall: DoAppCall;
|
||||
getChannel: (channelId: string) => Promise<ActionResult>;
|
||||
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
|
||||
doAppCall,
|
||||
getChannel,
|
||||
postEphemeralCallResponseForPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ButtonBinding);
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {LayoutChangeEvent, ScrollView, StyleProp, View} from 'react-native';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import ShowMoreButton from '@components/show_more_button';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
const SHOW_MORE_HEIGHT = 60;
|
||||
|
||||
type Props = {
|
||||
baseTextStyle: StyleProp<any>,
|
||||
blockStyles?: StyleProp<any>[],
|
||||
deviceHeight: number,
|
||||
onPermalinkPress?: () => void,
|
||||
textStyles?: StyleProp<any>[],
|
||||
theme?: Theme,
|
||||
value?: string,
|
||||
}
|
||||
|
||||
type State = {
|
||||
collapsed: boolean;
|
||||
isLongText: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export default class EmbedText extends PureComponent<Props, State> {
|
||||
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
|
||||
const {deviceHeight} = nextProps;
|
||||
const maxHeight = Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
|
||||
|
||||
if (maxHeight !== prevState.maxHeight) {
|
||||
return {
|
||||
maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
collapsed: true,
|
||||
isLongText: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleLayout = (event: LayoutChangeEvent) => {
|
||||
const {height} = event.nativeEvent.layout;
|
||||
const {maxHeight} = this.state;
|
||||
|
||||
if (height >= (maxHeight || 0)) {
|
||||
this.setState({
|
||||
isLongText: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapseState = () => {
|
||||
const {collapsed} = this.state;
|
||||
this.setState({collapsed: !collapsed});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
onPermalinkPress,
|
||||
textStyles,
|
||||
theme,
|
||||
value,
|
||||
} = this.props;
|
||||
const {collapsed, isLongText, maxHeight} = this.state;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
style={{maxHeight: (collapsed ? maxHeight : undefined), overflow: 'hidden'}}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
onLayout={this.handleLayout}
|
||||
removeClippedSubviews={isLongText && collapsed}
|
||||
>
|
||||
<Markdown
|
||||
baseTextStyle={baseTextStyle}
|
||||
textStyles={textStyles}
|
||||
blockStyles={blockStyles}
|
||||
disableGallery={true}
|
||||
value={value}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{isLongText &&
|
||||
<ShowMoreButton
|
||||
onPress={this.toggleCollapseState}
|
||||
showMore={collapsed}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import EmbeddedBinding from './embedded_binding';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {AppBinding} from '@mm-redux/types/apps';
|
||||
|
||||
type Props = {
|
||||
embeds: AppBinding[],
|
||||
baseTextStyle?: StyleProp<TextStyle>,
|
||||
blockStyles?: StyleProp<ViewStyle>[],
|
||||
deviceHeight: number,
|
||||
deviceWidth: number,
|
||||
postId: string,
|
||||
onPermalinkPress?: () => void,
|
||||
theme: Theme,
|
||||
textStyles?: StyleProp<TextStyle>[],
|
||||
}
|
||||
|
||||
export default function EmbeddedBindings(props: Props) {
|
||||
const {
|
||||
embeds,
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
deviceHeight,
|
||||
onPermalinkPress,
|
||||
postId,
|
||||
theme,
|
||||
textStyles,
|
||||
} = props;
|
||||
const content = [] as React.ReactNode[];
|
||||
|
||||
embeds.forEach((embed, i) => {
|
||||
content.push(
|
||||
<EmbeddedBinding
|
||||
embed={embed}
|
||||
baseTextStyle={baseTextStyle}
|
||||
blockStyles={blockStyles}
|
||||
deviceHeight={deviceHeight}
|
||||
key={'att_' + i}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
postId={postId}
|
||||
theme={theme}
|
||||
textStyles={textStyles}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{flex: 1, flexDirection: 'column'}}>
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
|
||||
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
|
||||
import {getPost} from '@mm-redux/selectors/entities/posts';
|
||||
|
||||
import MenuBinding from './menu_binding';
|
||||
import {getChannel} from '@mm-redux/actions/channels';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
|
||||
|
||||
type OwnProps = {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
return {
|
||||
post: getPost(state, ownProps.postId),
|
||||
currentTeamID: getCurrentTeamId(state),
|
||||
};
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
doAppCall: DoAppCall;
|
||||
getChannel: (channelId: string) => Promise<ActionResult>;
|
||||
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
|
||||
doAppCall,
|
||||
getChannel,
|
||||
postEphemeralCallResponseForPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(MenuBinding);
|
||||
@@ -1,137 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
|
||||
import AutocompleteSelector from 'app/components/autocomplete_selector';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {PostActionOption} from '@mm-redux/types/integration_actions';
|
||||
import {Post} from '@mm-redux/types/posts';
|
||||
import {AppBinding} from '@mm-redux/types/apps';
|
||||
import {ActionResult} from '@mm-redux/types/actions';
|
||||
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
|
||||
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
|
||||
import {Channel} from '@mm-redux/types/channels';
|
||||
import {createCallContext, createCallRequest} from '@utils/apps';
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
doAppCall: DoAppCall;
|
||||
getChannel: (channelId: string) => Promise<ActionResult>;
|
||||
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
|
||||
};
|
||||
binding?: AppBinding;
|
||||
post: Post;
|
||||
currentTeamID: string;
|
||||
}
|
||||
|
||||
type State = {
|
||||
selected?: PostActionOption;
|
||||
}
|
||||
|
||||
export default class MenuBinding extends PureComponent<Props, State> {
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
handleSelect = async (selected?: PostActionOption) => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({selected});
|
||||
const binding = this.props.binding?.bindings?.find((b) => b.location === selected.value);
|
||||
if (!binding) {
|
||||
console.debug('Trying to select element not present in binding.'); //eslint-disable-line no-console
|
||||
return;
|
||||
}
|
||||
|
||||
if (!binding.call) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
actions,
|
||||
post,
|
||||
currentTeamID,
|
||||
} = this.props;
|
||||
const intl = this.context.intl;
|
||||
|
||||
let teamID = '';
|
||||
const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any};
|
||||
if (data) {
|
||||
const channel = data as Channel;
|
||||
teamID = channel.team_id;
|
||||
}
|
||||
|
||||
const context = createCallContext(
|
||||
binding.app_id,
|
||||
AppBindingLocations.IN_POST + binding.location,
|
||||
post.channel_id,
|
||||
teamID || currentTeamID,
|
||||
post.id,
|
||||
);
|
||||
const call = createCallRequest(
|
||||
binding.call,
|
||||
context,
|
||||
{post: AppExpandLevels.EXPAND_ALL},
|
||||
);
|
||||
|
||||
const res = await actions.doAppCall(call, AppCallTypes.SUBMIT, intl);
|
||||
if (res.error) {
|
||||
const errorResponse = res.error;
|
||||
const errorMessage = errorResponse.error || intl.formatMessage({
|
||||
id: 'apps.error.unknown',
|
||||
defaultMessage: 'Unknown error occurred.',
|
||||
});
|
||||
this.props.actions.postEphemeralCallResponseForPost(res.error, errorMessage, post);
|
||||
return;
|
||||
}
|
||||
|
||||
const callResp = res.data!;
|
||||
switch (callResp.type) {
|
||||
case AppCallResponseTypes.OK:
|
||||
if (callResp.markdown) {
|
||||
this.props.actions.postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
|
||||
}
|
||||
return;
|
||||
case AppCallResponseTypes.NAVIGATE:
|
||||
case AppCallResponseTypes.FORM:
|
||||
return;
|
||||
default: {
|
||||
const errorMessage = intl.formatMessage({
|
||||
id: 'apps.error.responses.unknown_type',
|
||||
defaultMessage: 'App response type not supported. Response type: {type}.',
|
||||
}, {
|
||||
type: callResp.type,
|
||||
});
|
||||
this.props.actions.postEphemeralCallResponseForPost(callResp, errorMessage, post);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
binding,
|
||||
} = this.props;
|
||||
const {selected} = this.state;
|
||||
|
||||
const options = binding?.bindings?.map<PostActionOption>((b:AppBinding) => {
|
||||
return {text: b.label, value: b.location || ''};
|
||||
});
|
||||
|
||||
return (
|
||||
<AutocompleteSelector
|
||||
placeholder={binding?.label}
|
||||
options={options}
|
||||
selected={selected}
|
||||
onSelected={this.handleSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Text} from 'react-native';
|
||||
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {GlobalStyles} from 'app/styles';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FileAttachment should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"width": undefined,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<FileAttachmentImage
|
||||
file={
|
||||
Object {
|
||||
"create_at": 1546893090093,
|
||||
"data": Object {
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
"delete_at": 0,
|
||||
"extension": "png",
|
||||
"has_preview_image": true,
|
||||
"height": 171,
|
||||
"id": "fileId",
|
||||
"name": "image.png",
|
||||
"post_id": "postId",
|
||||
"size": 14894,
|
||||
"update_at": 1546893090093,
|
||||
"user_id": "userId",
|
||||
"width": 425,
|
||||
}
|
||||
}
|
||||
imageDimensions={null}
|
||||
resizeMethod="resize"
|
||||
resizeMode="cover"
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
@@ -1,38 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FileAttachmentImage should match snapshot 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderRadius": 5,
|
||||
"overflow": "hidden",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"paddingBottom": "100%",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Connect(ProgressiveImage)
|
||||
onError={[Function]}
|
||||
resizeMethod="resize"
|
||||
resizeMode="cover"
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
tintDefaultSource={true}
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,288 +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 {
|
||||
Dimensions,
|
||||
PixelRatio,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
|
||||
import * as Utils from '@mm-redux/utils/file_utils';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {isDocument, isImage} from '@utils/file';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import FileAttachmentDocument from './file_attachment_document';
|
||||
import FileAttachmentIcon from './file_attachment_icon';
|
||||
import FileAttachmentImage from './file_attachment_image';
|
||||
|
||||
export default class FileAttachment extends PureComponent {
|
||||
static propTypes = {
|
||||
canDownloadFiles: PropTypes.bool.isRequired,
|
||||
file: PropTypes.object.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
onLongPress: PropTypes.func,
|
||||
onPreviewPress: PropTypes.func,
|
||||
theme: PropTypes.object.isRequired,
|
||||
wrapperWidth: PropTypes.number,
|
||||
isSingleImage: PropTypes.bool,
|
||||
nonVisibleImagesCount: PropTypes.number,
|
||||
inViewPort: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onPreviewPress: () => true,
|
||||
wrapperWidth: 300,
|
||||
};
|
||||
|
||||
state = {
|
||||
resizeMode: 'cover',
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.transition) {
|
||||
clearTimeout(this.transition);
|
||||
}
|
||||
}
|
||||
|
||||
handlePress = () => {
|
||||
this.props.onPreviewPress(this.props.index);
|
||||
};
|
||||
|
||||
handlePreviewPress = () => {
|
||||
if (this.documentElement) {
|
||||
this.documentElement.handlePreviewPress();
|
||||
} else {
|
||||
this.props.onPreviewPress(this.props.index);
|
||||
}
|
||||
};
|
||||
|
||||
renderFileInfo() {
|
||||
const {file, onLongPress, theme} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
if (!file?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePress}
|
||||
onLongPress={onLongPress}
|
||||
type={'opacity'}
|
||||
style={style.attachmentContainer}
|
||||
>
|
||||
<React.Fragment>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.fileName}
|
||||
>
|
||||
{file.name.trim()}
|
||||
</Text>
|
||||
<View style={style.fileDownloadContainer}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.fileInfo}
|
||||
>
|
||||
{`${Utils.getFormattedFileSize(file)}`}
|
||||
</Text>
|
||||
</View>
|
||||
</React.Fragment>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
setDocumentRef = (ref) => {
|
||||
this.documentElement = ref;
|
||||
};
|
||||
|
||||
renderMoreImagesOverlay = (value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {theme} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View style={style.moreImagesWrapper}>
|
||||
<Text style={style.moreImagesText}>
|
||||
{`+${value}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
getImageDimensions = (file) => {
|
||||
const {isSingleImage, wrapperWidth} = this.props;
|
||||
const viewPortHeight = this.getViewPortHeight();
|
||||
|
||||
if (isSingleImage) {
|
||||
return calculateDimensions(file?.height, file?.width, wrapperWidth, viewPortHeight);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getViewPortHeight = () => {
|
||||
const dimensions = Dimensions.get('window');
|
||||
const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
|
||||
|
||||
return viewPortHeight;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
canDownloadFiles,
|
||||
file,
|
||||
theme,
|
||||
onLongPress,
|
||||
isSingleImage,
|
||||
nonVisibleImagesCount,
|
||||
inViewPort,
|
||||
} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
let fileAttachmentComponent;
|
||||
if (isImage(file) || file.loading) {
|
||||
const imageDimensions = this.getImageDimensions(file);
|
||||
|
||||
fileAttachmentComponent = (
|
||||
<TouchableWithFeedback
|
||||
key={`${this.props.id}${file.loading}`}
|
||||
onPress={this.handlePreviewPress}
|
||||
onLongPress={onLongPress}
|
||||
type={'opacity'}
|
||||
style={{width: imageDimensions?.width}}
|
||||
>
|
||||
<FileAttachmentImage
|
||||
file={file || {}}
|
||||
inViewPort={inViewPort}
|
||||
imageDimensions={imageDimensions}
|
||||
isSingleImage={isSingleImage}
|
||||
resizeMode={this.state.resizeMode}
|
||||
theme={theme}
|
||||
/>
|
||||
{this.renderMoreImagesOverlay(nonVisibleImagesCount, theme)}
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
} else if (isDocument(file)) {
|
||||
fileAttachmentComponent = (
|
||||
<View style={[style.fileWrapper]}>
|
||||
<View style={style.iconWrapper}>
|
||||
<FileAttachmentDocument
|
||||
ref={this.setDocumentRef}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file}
|
||||
onLongPress={onLongPress}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
{this.renderFileInfo()}
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
fileAttachmentComponent = (
|
||||
<View style={[style.fileWrapper]}>
|
||||
<View style={style.iconWrapper}>
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePreviewPress}
|
||||
onLongPress={onLongPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
<FileAttachmentIcon
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
{this.renderFileInfo()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return fileAttachmentComponent;
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
const scale = Dimensions.get('window').width / 320;
|
||||
|
||||
return {
|
||||
attachmentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
downloadIcon: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.7),
|
||||
marginRight: 5,
|
||||
},
|
||||
fileDownloadContainer: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 3,
|
||||
},
|
||||
fileInfo: {
|
||||
fontSize: 14,
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
fileName: {
|
||||
flexDirection: 'column',
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: theme.centerChannelColor,
|
||||
paddingRight: 10,
|
||||
},
|
||||
fileWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
marginTop: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
|
||||
borderRadius: 5,
|
||||
},
|
||||
iconWrapper: {
|
||||
marginTop: 7.8,
|
||||
marginRight: 6,
|
||||
marginBottom: 8.2,
|
||||
marginLeft: 8,
|
||||
},
|
||||
circularProgress: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
circularProgressContent: {
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
moreImagesWrapper: {
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 5,
|
||||
},
|
||||
moreImagesText: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)),
|
||||
fontFamily: 'Open Sans',
|
||||
textAlign: 'center',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import FileAttachment from './file_attachment.js';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
jest.mock('react-native-file-viewer', () => ({
|
||||
open: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('FileAttachment', () => {
|
||||
const baseProps = {
|
||||
canDownloadFiles: true,
|
||||
file: {
|
||||
create_at: 1546893090093,
|
||||
delete_at: 0,
|
||||
extension: 'png',
|
||||
has_preview_image: true,
|
||||
height: 171,
|
||||
id: 'fileId',
|
||||
name: 'image.png',
|
||||
post_id: 'postId',
|
||||
size: 14894,
|
||||
update_at: 1546893090093,
|
||||
user_id: 'userId',
|
||||
width: 425,
|
||||
data: {
|
||||
mime_type: 'image/png',
|
||||
},
|
||||
},
|
||||
id: 'id',
|
||||
index: 0,
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,311 +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 {
|
||||
Alert,
|
||||
Platform,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import FileViewer from 'react-native-file-viewer';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import {intlShape} from 'react-intl';
|
||||
import tinyColor from 'tinycolor2';
|
||||
|
||||
import FileAttachmentIcon from '@components/file_attachment_list/file_attachment_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import {DeviceTypes} from '@constants/';
|
||||
import {getFileUrl} from '@mm-redux/utils/file_utils';
|
||||
import {getLocalFilePathFromFile} from '@utils/file';
|
||||
import mattermostBucket from 'app/mattermost_bucket';
|
||||
|
||||
const {DOCUMENTS_PATH} = DeviceTypes;
|
||||
|
||||
export default class FileAttachmentDocument extends PureComponent {
|
||||
static propTypes = {
|
||||
backgroundColor: PropTypes.string,
|
||||
canDownloadFiles: PropTypes.bool.isRequired,
|
||||
file: PropTypes.object.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
onLongPress: PropTypes.func,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
state = {
|
||||
didCancel: false,
|
||||
downloading: false,
|
||||
preview: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
cancelDownload = () => {
|
||||
if (this.mounted) {
|
||||
this.setState({didCancel: true});
|
||||
}
|
||||
|
||||
if (this.downloadTask) {
|
||||
this.downloadTask.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
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 path = getLocalFilePathFromFile(DOCUMENTS_PATH, file);
|
||||
|
||||
this.setState({didCancel: false});
|
||||
|
||||
try {
|
||||
const certificate = await mattermostBucket.getPreference('cert');
|
||||
const isDir = await RNFetchBlob.fs.isDir(DOCUMENTS_PATH);
|
||||
if (!isDir) {
|
||||
try {
|
||||
await RNFetchBlob.fs.mkdir(DOCUMENTS_PATH);
|
||||
} catch (error) {
|
||||
this.showDownloadFailedAlert();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
session: file.id,
|
||||
timeout: 10000,
|
||||
indicator: true,
|
||||
overwrite: true,
|
||||
path,
|
||||
certificate,
|
||||
};
|
||||
|
||||
const exist = await RNFetchBlob.fs.exists(path);
|
||||
if (exist) {
|
||||
this.openDocument(file);
|
||||
} else {
|
||||
this.setState({downloading: true});
|
||||
this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(file.id));
|
||||
this.downloadTask.progress((received, total) => {
|
||||
const progress = parseFloat((received / total).toFixed(1));
|
||||
if (this.mounted) {
|
||||
this.setState({progress});
|
||||
}
|
||||
});
|
||||
|
||||
await this.downloadTask;
|
||||
if (this.mounted) {
|
||||
this.setState({
|
||||
progress: 1,
|
||||
}, () => {
|
||||
this.openDocument(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
RNFetchBlob.fs.unlink(path);
|
||||
if (this.mounted) {
|
||||
this.setState({downloading: false, progress: 0});
|
||||
|
||||
if (error.message !== 'cancelled') {
|
||||
this.showDownloadFailedAlert();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handlePreviewPress = async () => {
|
||||
const {canDownloadFiles, file} = this.props;
|
||||
const {downloading, progress} = this.state;
|
||||
|
||||
if (!canDownloadFiles) {
|
||||
this.showDownloadDisabledAlert();
|
||||
return;
|
||||
}
|
||||
|
||||
if (downloading && progress < 1) {
|
||||
this.cancelDownload();
|
||||
} else if (downloading) {
|
||||
this.resetViewState();
|
||||
} else {
|
||||
this.downloadAndPreviewFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
onDonePreviewingFile = () => {
|
||||
if (this.mounted) {
|
||||
this.setState({progress: 0, downloading: false, preview: false});
|
||||
}
|
||||
this.setStatusBarColor();
|
||||
};
|
||||
|
||||
openDocument = (file) => {
|
||||
if (!this.state.didCancel && !this.state.preview && this.mounted) {
|
||||
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, file);
|
||||
this.setState({preview: true});
|
||||
this.setStatusBarColor('dark-content');
|
||||
FileViewer.open(path, {
|
||||
displayName: file.name,
|
||||
onDismiss: this.onDonePreviewingFile,
|
||||
showOpenWithDialog: true,
|
||||
showAppsSuggestions: true,
|
||||
}).then(() => {
|
||||
if (this.mounted) {
|
||||
this.setState({downloading: false, progress: 0});
|
||||
}
|
||||
}).catch(() => {
|
||||
const {intl} = this.context;
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.document_preview.failed_title',
|
||||
defaultMessage: 'Open Document failed',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
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: file.extension.toUpperCase(),
|
||||
}),
|
||||
[{
|
||||
text: intl.formatMessage({
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}],
|
||||
);
|
||||
this.onDonePreviewingFile();
|
||||
RNFetchBlob.fs.unlink(path);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
resetViewState = () => {
|
||||
if (this.mounted) {
|
||||
this.setState({
|
||||
progress: 0,
|
||||
didCancel: true,
|
||||
downloading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
showDownloadDisabledAlert = () => {
|
||||
const {intl} = this.context;
|
||||
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.downloader.disabled_title',
|
||||
defaultMessage: 'Download disabled',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.downloader.disabled_description',
|
||||
defaultMessage: 'File downloads are disabled on this server. Please contact your System Admin for more details.\n',
|
||||
}),
|
||||
[{
|
||||
text: intl.formatMessage({
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}],
|
||||
);
|
||||
};
|
||||
|
||||
showDownloadFailedAlert = () => {
|
||||
const {intl} = this.context;
|
||||
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.downloader.failed_title',
|
||||
defaultMessage: 'Download failed',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.downloader.failed_description',
|
||||
defaultMessage: 'An error occurred while downloading the file. Please check your internet connection and try again.\n',
|
||||
}),
|
||||
[{
|
||||
text: intl.formatMessage({
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}],
|
||||
);
|
||||
};
|
||||
|
||||
renderFileAttachmentIcon = () => {
|
||||
const {backgroundColor, file, theme} = this.props;
|
||||
|
||||
return (
|
||||
<FileAttachmentIcon
|
||||
backgroundColor={backgroundColor}
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {onLongPress, theme} = this.props;
|
||||
const {downloading, progress} = this.state;
|
||||
let fileAttachmentComponent;
|
||||
if (downloading) {
|
||||
fileAttachmentComponent = (
|
||||
<>
|
||||
{this.renderFileAttachmentIcon()}
|
||||
<View style={[StyleSheet.absoluteFill, styles.progress]}>
|
||||
<ProgressBar
|
||||
progress={progress || 0.1}
|
||||
color={theme.buttonBg}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
fileAttachmentComponent = this.renderFileAttachmentIcon();
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePreviewPress}
|
||||
onLongPress={onLongPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
{fileAttachmentComponent}
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
progress: {
|
||||
justifyContent: 'flex-end',
|
||||
height: 48,
|
||||
left: 2,
|
||||
top: 5,
|
||||
width: 44,
|
||||
},
|
||||
});
|
||||
@@ -1,92 +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, StyleSheet} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import * as Utils from '@mm-redux/utils/file_utils';
|
||||
|
||||
const BLUE_ICON = '#338AFF';
|
||||
const RED_ICON = '#ED522A';
|
||||
const GREEN_ICON = '#1CA660';
|
||||
const GRAY_ICON = '#999999';
|
||||
const FAILED_ICON_NAME_AND_COLOR = ['jumbo-attachment-image-broken', GRAY_ICON];
|
||||
const ICON_NAME_AND_COLOR_FROM_FILE_TYPE = {
|
||||
audio: ['jumbo-attachment-audio', BLUE_ICON],
|
||||
code: ['jumbo-attachment-code', BLUE_ICON],
|
||||
image: ['jumbo-attachment-image', BLUE_ICON],
|
||||
smallImage: ['image-outline', BLUE_ICON],
|
||||
other: ['jumbo-attachment-generic', BLUE_ICON],
|
||||
patch: ['jumbo-attachment-patch', BLUE_ICON],
|
||||
pdf: ['jumbo-attachment-pdf', RED_ICON],
|
||||
presentation: ['jumbo-attachment-powerpoint', RED_ICON],
|
||||
spreadsheet: ['jumbo-attachment-excel', GREEN_ICON],
|
||||
text: ['jumbo-attachment-text', GRAY_ICON],
|
||||
video: ['jumbo-attachment-video', BLUE_ICON],
|
||||
word: ['jumbo-attachment-word', BLUE_ICON],
|
||||
zip: ['jumbo-attachment-zip', BLUE_ICON],
|
||||
};
|
||||
|
||||
export default class FileAttachmentIcon extends PureComponent {
|
||||
static propTypes = {
|
||||
backgroundColor: PropTypes.string,
|
||||
failed: PropTypes.bool,
|
||||
defaultImage: PropTypes.bool,
|
||||
smallImage: PropTypes.bool,
|
||||
file: PropTypes.object,
|
||||
iconColor: PropTypes.string,
|
||||
iconSize: PropTypes.number,
|
||||
theme: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
failed: false,
|
||||
defaultImage: false,
|
||||
smallImage: false,
|
||||
iconSize: 48,
|
||||
};
|
||||
|
||||
getFileIconNameAndColor(file) {
|
||||
if (this.props.failed) {
|
||||
return FAILED_ICON_NAME_AND_COLOR;
|
||||
}
|
||||
|
||||
if (this.props.defaultImage) {
|
||||
if (this.props.smallImage) {
|
||||
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.smallImage;
|
||||
}
|
||||
|
||||
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.image;
|
||||
}
|
||||
|
||||
const fileType = Utils.getFileType(file);
|
||||
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE[fileType] || ICON_NAME_AND_COLOR_FROM_FILE_TYPE.other;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {backgroundColor, file, iconSize, theme, iconColor} = this.props;
|
||||
const [iconName, defaultIconColor] = this.getFileIconNameAndColor(file);
|
||||
const color = iconColor || defaultIconColor;
|
||||
const bgColor = backgroundColor || theme?.centerChannelBg || 'transparent';
|
||||
|
||||
return (
|
||||
<View style={[styles.fileIconWrapper, {backgroundColor: bgColor}]}>
|
||||
<CompassIcon
|
||||
name={iconName}
|
||||
size={iconSize}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fileIconWrapper: {
|
||||
borderRadius: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -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 {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import {Client4} from '@client/rest';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import FileAttachmentIcon from './file_attachment_icon';
|
||||
|
||||
const SMALL_IMAGE_MAX_HEIGHT = 48;
|
||||
const SMALL_IMAGE_MAX_WIDTH = 48;
|
||||
|
||||
const IMAGE_SIZE = {
|
||||
Fullsize: 'fullsize',
|
||||
Preview: 'preview',
|
||||
Thumbnail: 'thumbnail',
|
||||
};
|
||||
|
||||
export default class FileAttachmentImage extends PureComponent {
|
||||
static propTypes = {
|
||||
file: PropTypes.object.isRequired,
|
||||
imageHeight: PropTypes.number,
|
||||
imageSize: PropTypes.oneOf([
|
||||
IMAGE_SIZE.Fullsize,
|
||||
IMAGE_SIZE.Preview,
|
||||
IMAGE_SIZE.Thumbnail,
|
||||
]),
|
||||
imageWidth: PropTypes.number,
|
||||
theme: PropTypes.object,
|
||||
resizeMode: PropTypes.string,
|
||||
resizeMethod: PropTypes.string,
|
||||
isSingleImage: PropTypes.bool,
|
||||
imageDimensions: PropTypes.object,
|
||||
backgroundColor: PropTypes.string,
|
||||
inViewPort: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
resizeMode: 'cover',
|
||||
resizeMethod: 'resize',
|
||||
};
|
||||
|
||||
state = {
|
||||
failed: false,
|
||||
};
|
||||
|
||||
boxPlaceholder = () => {
|
||||
if (this.props.isSingleImage) {
|
||||
return null;
|
||||
}
|
||||
return (<View style={style.boxPlaceholder}/>);
|
||||
};
|
||||
|
||||
handleError = () => {
|
||||
this.setState({failed: true});
|
||||
}
|
||||
|
||||
imageProps = (file) => {
|
||||
const imageProps = {};
|
||||
|
||||
if (file.localPath) {
|
||||
imageProps.defaultSource = {uri: file.localPath};
|
||||
} else if (file.id) {
|
||||
if (file.mini_preview && file.mime_type) {
|
||||
imageProps.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`;
|
||||
} else {
|
||||
imageProps.thumbnailUri = Client4.getFileThumbnailUrl(file.id);
|
||||
}
|
||||
imageProps.imageUri = Client4.getFilePreviewUrl(file.id);
|
||||
imageProps.inViewPort = this.props.inViewPort;
|
||||
}
|
||||
return imageProps;
|
||||
};
|
||||
|
||||
renderSmallImage = () => {
|
||||
const {file, isSingleImage, resizeMethod, theme} = this.props;
|
||||
|
||||
let wrapperStyle = style.fileImageWrapper;
|
||||
|
||||
if (isSingleImage) {
|
||||
wrapperStyle = style.singleSmallImageWrapper;
|
||||
|
||||
if (file.width > SMALL_IMAGE_MAX_WIDTH) {
|
||||
wrapperStyle = [wrapperStyle, {width: '100%'}];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
wrapperStyle,
|
||||
style.smallImageBorder,
|
||||
{
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{this.boxPlaceholder()}
|
||||
<View style={style.smallImageOverlay}>
|
||||
<ProgressiveImage
|
||||
id={file.id}
|
||||
style={{height: file.height, width: file.width}}
|
||||
tintDefaultSource={!file.localPath && !this.state.failed}
|
||||
filename={file.name}
|
||||
onError={this.handleError}
|
||||
resizeMode={'contain'}
|
||||
resizeMethod={resizeMethod}
|
||||
{...this.imageProps(file)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {failed} = this.state;
|
||||
const {
|
||||
file,
|
||||
imageDimensions,
|
||||
resizeMethod,
|
||||
resizeMode,
|
||||
backgroundColor,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<FileAttachmentIcon
|
||||
failed={failed}
|
||||
backgroundColor={backgroundColor}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) {
|
||||
return this.renderSmallImage();
|
||||
}
|
||||
|
||||
const imageProps = this.imageProps(file);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={style.fileImageWrapper}
|
||||
>
|
||||
{this.boxPlaceholder()}
|
||||
<ProgressiveImage
|
||||
id={file.id}
|
||||
style={[this.props.isSingleImage ? null : style.imagePreview, imageDimensions]}
|
||||
tintDefaultSource={!file.localPath && !this.state.failed}
|
||||
filename={file.name}
|
||||
onError={this.handleError}
|
||||
resizeMode={resizeMode}
|
||||
resizeMethod={resizeMethod}
|
||||
{...imageProps}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
imagePreview: {
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
fileImageWrapper: {
|
||||
borderRadius: 5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
boxPlaceholder: {
|
||||
paddingBottom: '100%',
|
||||
},
|
||||
smallImageBorder: {
|
||||
borderRadius: 5,
|
||||
},
|
||||
smallImageOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
singleSmallImageWrapper: {
|
||||
height: SMALL_IMAGE_MAX_HEIGHT,
|
||||
width: SMALL_IMAGE_MAX_WIDTH,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import {Client4} from '@client/rest';
|
||||
|
||||
import FileAttachmentImage from './file_attachment_image.js';
|
||||
|
||||
describe('FileAttachmentImage', () => {
|
||||
const baseProps = {
|
||||
file: {},
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachmentImage {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('imageProps', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachmentImage {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
it('should have file.localPath as defaultSource if localPath is set', () => {
|
||||
wrapper.setState({failed: false});
|
||||
const file = {localPath: '/localPath.png'};
|
||||
const imageProps = instance.imageProps(file);
|
||||
expect(imageProps.defaultSource).toStrictEqual({uri: file.localPath});
|
||||
expect(imageProps.thumbnailUri).toBeUndefined();
|
||||
expect(imageProps.imageUri).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have thumbnailUri and imageUri if the file has an ID', () => {
|
||||
const getFileThumbnailUrl = jest.spyOn(Client4, 'getFileThumbnailUrl');
|
||||
const getFilePreviewUrl = jest.spyOn(Client4, 'getFilePreviewUrl');
|
||||
|
||||
wrapper.setState({failed: false});
|
||||
const file = {id: 'id'};
|
||||
const imageProps = instance.imageProps(file);
|
||||
expect(getFileThumbnailUrl).toHaveBeenCalled();
|
||||
expect(getFilePreviewUrl).toHaveBeenCalled();
|
||||
expect(imageProps.defaultSource).toBeUndefined();
|
||||
expect(imageProps.thumbnailUri).toBeDefined();
|
||||
expect(imageProps.imageUri).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {StyleSheet, View, DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import ImageViewPort from '@components/image_viewport';
|
||||
import {Client4} from '@client/rest';
|
||||
import {isDocument, isGif, isImage, isVideo} from '@utils/file';
|
||||
import {getViewPortWidth, openGalleryAtIndex} from '@utils/images';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
|
||||
import FileAttachment from './file_attachment';
|
||||
|
||||
const MAX_VISIBLE_ROW_IMAGES = 4;
|
||||
|
||||
export default class FileAttachmentList extends ImageViewPort {
|
||||
static propTypes = {
|
||||
canDownloadFiles: PropTypes.bool.isRequired,
|
||||
fileIds: PropTypes.array.isRequired,
|
||||
files: PropTypes.array,
|
||||
isFailed: PropTypes.bool,
|
||||
onLongPress: PropTypes.func,
|
||||
postId: PropTypes.string.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
isReplyPost: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
files: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.filesForGallery = this.getFilesForGallery(props);
|
||||
|
||||
this.buildGalleryFiles().then((results) => {
|
||||
this.galleryFiles = results;
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
super.componentDidMount();
|
||||
this.onScrollEnd = DeviceEventEmitter.addListener('scrolled', (viewableItems) => {
|
||||
if (this.props.postId in viewableItems) {
|
||||
this.setState({
|
||||
inViewPort: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.files.length !== this.props.files.length) {
|
||||
this.filesForGallery = this.getFilesForGallery(this.props);
|
||||
this.buildGalleryFiles().then((results) => {
|
||||
this.galleryFiles = results;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
super.componentWillUnmount();
|
||||
if (this.onScrollEnd && this.onScrollEnd.remove) {
|
||||
this.onScrollEnd.remove();
|
||||
}
|
||||
}
|
||||
|
||||
attachmentIndex = (fileId) => {
|
||||
return this.filesForGallery.findIndex((file) => file.id === fileId) || 0;
|
||||
};
|
||||
|
||||
attachmentManifest = (attachments) => {
|
||||
return attachments.reduce((info, file) => {
|
||||
if (isImage(file)) {
|
||||
info.imageAttachments.push(file);
|
||||
} else {
|
||||
info.nonImageAttachments.push(file);
|
||||
}
|
||||
return info;
|
||||
}, {imageAttachments: [], nonImageAttachments: []});
|
||||
};
|
||||
|
||||
buildGalleryFiles = async () => {
|
||||
const results = [];
|
||||
|
||||
if (this.filesForGallery && this.filesForGallery.length) {
|
||||
for (let i = 0; i < this.filesForGallery.length; i++) {
|
||||
const file = this.filesForGallery[i];
|
||||
if (isDocument(file) || isVideo(file) || (!isImage(file))) {
|
||||
results.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
let uri;
|
||||
if (file.localPath) {
|
||||
uri = file.localPath;
|
||||
} else {
|
||||
uri = isGif(file) ? Client4.getFileUrl(file.id) : Client4.getFilePreviewUrl(file.id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
...file,
|
||||
uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
getFilesForGallery = (props) => {
|
||||
const manifest = this.attachmentManifest(props.files);
|
||||
const files = manifest.imageAttachments.concat(manifest.nonImageAttachments);
|
||||
const results = [];
|
||||
|
||||
if (files && files.length) {
|
||||
files.forEach((file) => {
|
||||
results.push(file);
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
handlePreviewPress = preventDoubleTap((idx) => {
|
||||
openGalleryAtIndex(idx, this.galleryFiles);
|
||||
});
|
||||
|
||||
isSingleImage = (files) => (files.length === 1 && isImage(files[0]));
|
||||
|
||||
renderItems = (items, moreImagesCount, includeGutter = false) => {
|
||||
const {canDownloadFiles, isReplyPost, onLongPress, theme} = this.props;
|
||||
const isSingleImage = this.isSingleImage(items);
|
||||
let nonVisibleImagesCount;
|
||||
let container = styles.container;
|
||||
const containerWithGutter = [container, styles.gutter];
|
||||
|
||||
return items.map((file, idx) => {
|
||||
if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) {
|
||||
nonVisibleImagesCount = moreImagesCount;
|
||||
}
|
||||
|
||||
if (idx !== 0 && includeGutter) {
|
||||
container = containerWithGutter;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={container}
|
||||
key={file.id}
|
||||
>
|
||||
<FileAttachment
|
||||
key={file.id}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file}
|
||||
id={file.id}
|
||||
index={this.attachmentIndex(file.id)}
|
||||
onPreviewPress={this.handlePreviewPress}
|
||||
onLongPress={onLongPress}
|
||||
theme={theme}
|
||||
isSingleImage={isSingleImage}
|
||||
nonVisibleImagesCount={nonVisibleImagesCount}
|
||||
wrapperWidth={getViewPortWidth(isReplyPost, this.hasPermanentSidebar())}
|
||||
inViewPort={this.state.inViewPort}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
renderImageRow = (images) => {
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {isReplyPost} = this.props;
|
||||
const visibleImages = images.slice(0, MAX_VISIBLE_ROW_IMAGES);
|
||||
const hasFixedSidebar = this.hasPermanentSidebar();
|
||||
const portraitPostWidth = getViewPortWidth(isReplyPost, hasFixedSidebar);
|
||||
|
||||
let nonVisibleImagesCount;
|
||||
if (images.length > MAX_VISIBLE_ROW_IMAGES) {
|
||||
nonVisibleImagesCount = images.length - MAX_VISIBLE_ROW_IMAGES;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.row, {width: portraitPostWidth}]}>
|
||||
{ this.renderItems(visibleImages, nonVisibleImagesCount, true) }
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {canDownloadFiles, fileIds, files, isFailed} = this.props;
|
||||
|
||||
if (!files.length && fileIds.length > 0) {
|
||||
return fileIds.map((id, idx) => (
|
||||
<FileAttachment
|
||||
key={id}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={{loading: true}}
|
||||
id={id}
|
||||
index={idx}
|
||||
theme={this.props.theme}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const manifest = this.attachmentManifest(files);
|
||||
|
||||
return (
|
||||
<View style={[isFailed && styles.failed]}>
|
||||
{this.renderImageRow(manifest.imageAttachments)}
|
||||
{this.renderItems(manifest.nonImageAttachments)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
marginTop: 5,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gutter: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
failed: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import FileAttachment from './file_attachment_list.js';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
jest.mock('react-native-file-viewer', () => ({
|
||||
open: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('FileAttachmentList', () => {
|
||||
const files = [{
|
||||
create_at: 1546893090093,
|
||||
delete_at: 0,
|
||||
extension: 'png',
|
||||
has_preview_image: true,
|
||||
height: 171,
|
||||
id: 'fileId',
|
||||
mime_type: 'image/png',
|
||||
name: 'image01.png',
|
||||
post_id: 'postId',
|
||||
size: 14894,
|
||||
update_at: 1546893090093,
|
||||
user_id: 'userId',
|
||||
width: 425,
|
||||
},
|
||||
{
|
||||
create_at: 1546893090093,
|
||||
delete_at: 0,
|
||||
extension: 'png',
|
||||
has_preview_image: true,
|
||||
height: 800,
|
||||
id: 'otherFileId',
|
||||
mime_type: 'image/png',
|
||||
name: 'image02.png',
|
||||
post_id: 'postId',
|
||||
size: 24894,
|
||||
update_at: 1546893090093,
|
||||
user_id: 'userId',
|
||||
width: 555,
|
||||
}];
|
||||
|
||||
const nonImage = {
|
||||
extension: 'other',
|
||||
id: 'fileId',
|
||||
mime_type: 'other/type',
|
||||
name: 'file01.other',
|
||||
post_id: 'postId',
|
||||
size: 14894,
|
||||
user_id: 'userId',
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
canDownloadFiles: true,
|
||||
deviceHeight: 680,
|
||||
deviceWidth: 660,
|
||||
fileIds: ['fileId'],
|
||||
files: [files[0]],
|
||||
postId: 'postId',
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
test('should match snapshot with a single image file', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with two image files', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
files,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with three image files', () => {
|
||||
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [...files, thirdImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with four image files', () => {
|
||||
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
|
||||
const fourthImage = {...files[1], id: 'fourthFileId', name: 'image04.png'};
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [...files, thirdImage, fourthImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with more than four image files', () => {
|
||||
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
|
||||
const fourthImage = {...files[1], id: 'fourthFileId', name: 'image04.png'};
|
||||
const fifthImage = {...files[1], id: 'fifthFileId', name: 'image05.png'};
|
||||
const sixthImage = {...files[1], id: 'sixthFileId', name: 'image06.png'};
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [...files, thirdImage, fourthImage, fifthImage, sixthImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with non-image attachment', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [nonImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with combination of image and non-image attachments', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [...files, nonImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should call getFilesForGallery on props change', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
wrapper.instance().getFilesForGallery = jest.fn().mockImplementationOnce(() => []);
|
||||
wrapper.setProps({files: [files[0], files[1]]});
|
||||
expect(wrapper.instance().getFilesForGallery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment-timezone';
|
||||
import {Text} from 'react-native';
|
||||
|
||||
export default class FormattedDate extends React.PureComponent {
|
||||
static propTypes = {
|
||||
format: PropTypes.string,
|
||||
timeZone: PropTypes.string,
|
||||
value: PropTypes.any.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
format: 'ddd, MMM DD, YYYY',
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
format,
|
||||
timeZone,
|
||||
value,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
let formattedDate = moment(value).format(format);
|
||||
if (timeZone) {
|
||||
formattedDate = moment.tz(value, timeZone).format(format);
|
||||
}
|
||||
|
||||
return <Text {...props}>{formattedDate}</Text>;
|
||||
}
|
||||
}
|
||||
33
app/components/formatted_date.tsx
Normal file
33
app/components/formatted_date.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import React from 'react';
|
||||
import {Text, TextProps} from 'react-native';
|
||||
|
||||
import type {UserTimezone} from '@mm-redux/types/users';
|
||||
|
||||
type FormattedDateProps = TextProps & {
|
||||
format: string;
|
||||
timezone?: string | UserTimezone | null;
|
||||
value: number | string | Date;
|
||||
}
|
||||
|
||||
const FormattedDate = ({format, timezone, value, ...props}: FormattedDateProps) => {
|
||||
let formattedDate = moment(value).format(format);
|
||||
if (timezone) {
|
||||
let zone = timezone as string;
|
||||
if (typeof timezone === 'object') {
|
||||
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
|
||||
}
|
||||
formattedDate = moment.tz(value, zone).format(format);
|
||||
}
|
||||
|
||||
return <Text {...props}>{formattedDate}</Text>;
|
||||
};
|
||||
|
||||
FormattedDate.defaultProps = {
|
||||
format: 'ddd, MMM DD, YYYY',
|
||||
};
|
||||
|
||||
export default FormattedDate;
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {createElement, isValidElement} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Text} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
export default class FormattedText extends React.PureComponent {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
defaultMessage: PropTypes.string,
|
||||
values: PropTypes.object,
|
||||
testID: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
defaultMessage: '',
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
defaultMessage,
|
||||
values,
|
||||
...props
|
||||
} = this.props;
|
||||
const {formatMessage} = this.context.intl;
|
||||
|
||||
let tokenDelimiter;
|
||||
let tokenizedValues;
|
||||
let elements;
|
||||
const hasValues = values && Object.keys(values).length > 0;
|
||||
if (hasValues) {
|
||||
// Creates a token with a random UID that should not be guessable or
|
||||
// conflict with other parts of the `message` string.
|
||||
const uid = Math.floor(Math.random() * 0x10000000000).toString(16);
|
||||
|
||||
const generateToken = (() => {
|
||||
let counter = 0;
|
||||
return () => {
|
||||
const elementId = `ELEMENT-${uid}-${counter += 1}`;
|
||||
return elementId;
|
||||
};
|
||||
})();
|
||||
|
||||
// Splitting with a delimiter to support IE8. When using a regex
|
||||
// with a capture group IE8 does not include the capture group in
|
||||
// the resulting array.
|
||||
tokenDelimiter = `@__${uid}__@`;
|
||||
tokenizedValues = {};
|
||||
elements = {};
|
||||
|
||||
// Iterates over the `props` to keep track of any React Element
|
||||
// values so they can be represented by the `token` as a placeholder
|
||||
// when the `message` is formatted. This allows the formatted
|
||||
// message to then be broken-up into parts with references to the
|
||||
// React Elements inserted back in.
|
||||
Object.keys(values).forEach((name) => {
|
||||
const value = values[name];
|
||||
|
||||
if (isValidElement(value)) {
|
||||
const token = generateToken();
|
||||
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;
|
||||
elements[token] = value;
|
||||
} else {
|
||||
tokenizedValues[name] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const descriptor = {id, defaultMessage};
|
||||
const formattedMessage = formatMessage(descriptor, tokenizedValues || values);
|
||||
const hasElements = elements && Object.keys(elements).length > 0;
|
||||
|
||||
let nodes;
|
||||
if (hasElements) {
|
||||
// Split the message into parts so the React Element values captured
|
||||
// above can be inserted back into the rendered message. This
|
||||
// approach allows messages to render with React Elements while
|
||||
// keeping React's virtual diffing working properly.
|
||||
nodes = formattedMessage.
|
||||
split(tokenDelimiter).
|
||||
filter((part) => Boolean(part)).
|
||||
map((part) => elements[part] || part);
|
||||
} else {
|
||||
nodes = [formattedMessage];
|
||||
}
|
||||
|
||||
return createElement(Text, props, ...nodes);
|
||||
}
|
||||
}
|
||||
83
app/components/formatted_text.tsx
Normal file
83
app/components/formatted_text.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {createElement, isValidElement} from 'react';
|
||||
import {Text, TextProps} from 'react-native';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
|
||||
type FormattedTextProps = TextProps & {
|
||||
id: string;
|
||||
defaultMessage: string;
|
||||
intl: typeof intlShape;
|
||||
values?: Record<string, React.ReactElement<unknown, string | React.JSXElementConstructor<any>>>;
|
||||
}
|
||||
|
||||
const FormattedText = ({id, defaultMessage, intl, values, ...props}: FormattedTextProps) => {
|
||||
const {formatMessage} = intl;
|
||||
|
||||
let tokenDelimiter = '';
|
||||
const tokenizedValues: Record<string, string|number> = {};
|
||||
const elements: Record<string, React.ReactElement<unknown, string | React.JSXElementConstructor<any>>> = {};
|
||||
|
||||
if (values && Object.keys(values).length > 0) {
|
||||
// Creates a token with a random UID that should not be guessable or
|
||||
// conflict with other parts of the `message` string.
|
||||
const uid = Math.floor(Math.random() * 0x10000000000).toString(16);
|
||||
|
||||
const generateToken = (() => {
|
||||
let counter = 0;
|
||||
return () => {
|
||||
const elementId = `ELEMENT-${uid}-${counter += 1}`;
|
||||
return elementId;
|
||||
};
|
||||
})();
|
||||
|
||||
// Splitting with a delimiter to support IE8. When using a regex
|
||||
// with a capture group IE8 does not include the capture group in
|
||||
// the resulting array.
|
||||
tokenDelimiter = `@__${uid}__@`;
|
||||
|
||||
// Iterates over the `props` to keep track of any React Element
|
||||
// values so they can be represented by the `token` as a placeholder
|
||||
// when the `message` is formatted. This allows the formatted
|
||||
// message to then be broken-up into parts with references to the
|
||||
// React Elements inserted back in.
|
||||
Object.keys(values).forEach((name) => {
|
||||
const value = values[name];
|
||||
|
||||
if (isValidElement(value)) {
|
||||
const token = generateToken();
|
||||
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;
|
||||
elements[token] = value;
|
||||
} else {
|
||||
tokenizedValues[name] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const descriptor = {id, defaultMessage};
|
||||
const formattedMessage = formatMessage(descriptor, tokenizedValues || values);
|
||||
const hasElements = elements && Object.keys(elements).length > 0;
|
||||
|
||||
let nodes;
|
||||
if (hasElements) {
|
||||
// Split the message into parts so the React Element values captured
|
||||
// above can be inserted back into the rendered message. This
|
||||
// approach allows messages to render with React Elements while
|
||||
// keeping React's virtual diffing working properly.
|
||||
nodes = formattedMessage.
|
||||
split(tokenDelimiter).
|
||||
filter((part: string) => Boolean(part)).
|
||||
map((part: string) => elements[part] || part);
|
||||
} else {
|
||||
nodes = [formattedMessage];
|
||||
}
|
||||
|
||||
return createElement(Text, props, ...nodes);
|
||||
};
|
||||
|
||||
FormattedText.defautProps = {
|
||||
defaultMessage: '',
|
||||
};
|
||||
|
||||
export default injectIntl(FormattedText);
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Text} from 'react-native';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
export default class FormattedTime extends React.PureComponent {
|
||||
static propTypes = {
|
||||
value: PropTypes.any.isRequired,
|
||||
timeZone: PropTypes.string,
|
||||
children: PropTypes.func,
|
||||
hour12: PropTypes.bool,
|
||||
style: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
testID: PropTypes.string,
|
||||
};
|
||||
|
||||
getFormattedTime = () => {
|
||||
const {
|
||||
value,
|
||||
timeZone,
|
||||
hour12,
|
||||
} = this.props;
|
||||
|
||||
let format = 'H:mm';
|
||||
if (hour12) {
|
||||
const localeFormat = moment.localeData().longDateFormat('LT');
|
||||
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
|
||||
}
|
||||
|
||||
if (timeZone) {
|
||||
return moment.tz(value, timeZone).format(format);
|
||||
}
|
||||
|
||||
return moment(value).format(format);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {children, style, testID} = this.props;
|
||||
const formattedTime = this.getFormattedTime();
|
||||
|
||||
if (typeof children === 'function') {
|
||||
return children(formattedTime);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={style}
|
||||
testID={testID}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import FormattedTime from './formatted_time';
|
||||
describe('FormattedTime', () => {
|
||||
const baseProps = {
|
||||
value: 1548788533405,
|
||||
timeZone: 'UTC',
|
||||
hour12: true,
|
||||
timezone: 'UTC',
|
||||
isMilitaryTime: false,
|
||||
};
|
||||
|
||||
it('should render correctly', () => {
|
||||
@@ -28,7 +28,7 @@ describe('FormattedTime', () => {
|
||||
const viewTwo = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
hour12={false}
|
||||
isMilitaryTime={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('FormattedTime', () => {
|
||||
const koViewTwo = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
hour12={false}
|
||||
isMilitaryTime={true}
|
||||
/>,
|
||||
'ko',
|
||||
);
|
||||
@@ -72,7 +72,7 @@ describe('FormattedTime', () => {
|
||||
const viewOne = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
timeZone='NZ-CHAT'
|
||||
timezone='NZ-CHAT'
|
||||
/>,
|
||||
'es',
|
||||
);
|
||||
@@ -83,8 +83,8 @@ describe('FormattedTime', () => {
|
||||
const viewTwo = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
timeZone='NZ-CHAT'
|
||||
hour12={false}
|
||||
timezone='NZ-CHAT'
|
||||
isMilitaryTime={true}
|
||||
/>,
|
||||
'es',
|
||||
);
|
||||
|
||||
41
app/components/formatted_time.tsx
Normal file
41
app/components/formatted_time.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import React from 'react';
|
||||
import {Text, TextProps} from 'react-native';
|
||||
|
||||
import type {UserTimezone} from '@mm-redux/types/users';
|
||||
|
||||
type FormattedTimeProps = TextProps & {
|
||||
isMilitaryTime: boolean;
|
||||
timezone: UserTimezone | string;
|
||||
value: number | string | Date;
|
||||
}
|
||||
|
||||
const FormattedTime = ({isMilitaryTime, timezone, value, ...props}: FormattedTimeProps) => {
|
||||
const getFormattedTime = () => {
|
||||
let format = 'H:mm';
|
||||
if (!isMilitaryTime) {
|
||||
const localeFormat = moment.localeData().longDateFormat('LT');
|
||||
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
|
||||
}
|
||||
|
||||
let zone = timezone as string;
|
||||
if (typeof timezone === 'object') {
|
||||
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
|
||||
}
|
||||
|
||||
return timezone ? moment.tz(value, zone).format(format) : moment(value).format(format);
|
||||
};
|
||||
|
||||
const formattedTime = getFormattedTime();
|
||||
|
||||
return (
|
||||
<Text {...props}>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormattedTime;
|
||||
@@ -10,9 +10,12 @@ import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
|
||||
// TODO: Use permanentSidebar and splitView hooks instead
|
||||
export default class ImageViewPort extends PureComponent {
|
||||
mounted = false;
|
||||
state = {
|
||||
inViewPort: false,
|
||||
isSplitView: false,
|
||||
permanentSidebar: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@@ -32,7 +35,7 @@ export default class ImageViewPort extends PureComponent {
|
||||
handleDimensions = () => {
|
||||
if (this.mounted) {
|
||||
if (DeviceTypes.IS_TABLET) {
|
||||
mattermostManaged.isRunningInSplitView().then((result) => {
|
||||
mattermostManaged.isRunningInSplitView().then((result: any) => {
|
||||
const isSplitView = Boolean(result.isSplitView);
|
||||
this.setState({isSplitView});
|
||||
});
|
||||
@@ -2,18 +2,16 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import InteractiveDialogController from './interactive_dialog_controller';
|
||||
|
||||
jest.mock('react-intl');
|
||||
|
||||
describe('InteractiveDialogController', () => {
|
||||
test('should open interactive dialog as alert or screen depending on with or without element', () => {
|
||||
let baseProps = getBaseProps('trigger_id_1');
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<InteractiveDialogController {...baseProps}/>,
|
||||
{context: {intl: {formatMessage: jest.fn()}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ViewPropTypes,
|
||||
} from 'react-native';
|
||||
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
||||
@@ -11,26 +11,16 @@ export default class Hashtag extends React.PureComponent {
|
||||
static propTypes = {
|
||||
hashtag: PropTypes.string.isRequired,
|
||||
linkStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
onHashtagPress: PropTypes.func,
|
||||
};
|
||||
|
||||
handlePress = async () => {
|
||||
const {
|
||||
onHashtagPress,
|
||||
hashtag,
|
||||
} = this.props;
|
||||
|
||||
if (onHashtagPress) {
|
||||
onHashtagPress(hashtag);
|
||||
|
||||
return;
|
||||
}
|
||||
const {hashtag} = this.props;
|
||||
|
||||
// Close thread view, permalink view, etc
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
|
||||
showSearchModal('#' + this.props.hashtag);
|
||||
showSearchModal('#' + hashtag);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -37,25 +37,4 @@ describe('Hashtag', () => {
|
||||
expect(popToRoot).toHaveBeenCalled();
|
||||
expect(showSearchModal).toHaveBeenCalledWith('#test');
|
||||
});
|
||||
|
||||
test('handlePress should call onHashtagPress if provided', async () => {
|
||||
const dismissAllModals = jest.spyOn(NavigationActions, 'dismissAllModals');
|
||||
const popToRoot = jest.spyOn(NavigationActions, 'popToRoot');
|
||||
const showSearchModal = jest.spyOn(NavigationActions, 'showSearchModal');
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHashtagPress: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(<Hashtag {...props}/>);
|
||||
|
||||
await wrapper.instance().handlePress();
|
||||
|
||||
expect(dismissAllModals).not.toBeCalled();
|
||||
expect(popToRoot).not.toBeCalled();
|
||||
expect(showSearchModal).not.toBeCalled();
|
||||
|
||||
expect(props.onHashtagPress).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,13 +49,11 @@ export default class Markdown extends PureComponent {
|
||||
mentionKeys: PropTypes.array,
|
||||
minimumHashtagLength: PropTypes.number,
|
||||
onChannelLinkPress: PropTypes.func,
|
||||
onHashtagPress: PropTypes.func,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
onPostPress: PropTypes.func,
|
||||
postId: PropTypes.string,
|
||||
textStyles: PropTypes.object,
|
||||
theme: PropTypes.object,
|
||||
value: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
disableHashtags: PropTypes.bool,
|
||||
disableAtMentions: PropTypes.bool,
|
||||
disableChannelLink: PropTypes.bool,
|
||||
@@ -274,7 +272,6 @@ export default class Markdown extends PureComponent {
|
||||
<Hashtag
|
||||
hashtag={hashtag}
|
||||
linkStyle={this.props.textStyles.link}
|
||||
onHashtagPress={this.props.onHashtagPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -417,10 +414,7 @@ export default class Markdown extends PureComponent {
|
||||
|
||||
renderLink = ({children, href}) => {
|
||||
return (
|
||||
<MarkdownLink
|
||||
href={href}
|
||||
onPermalinkPress={this.props.onPermalinkPress}
|
||||
>
|
||||
<MarkdownLink href={href}>
|
||||
{children}
|
||||
</MarkdownLink>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
// 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 {
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {Keyboard, StyleSheet, Text, View} from 'react-native';
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import {PropTypes} from 'prop-types';
|
||||
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import BottomSheet from 'app/utils/bottom_sheet';
|
||||
import {getDisplayNameForLanguage} from 'app/utils/markdown';
|
||||
import {preventDoubleTap} from 'app/utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
import {goToScreen} from 'app/actions/navigation';
|
||||
import {goToScreen} from '@actions/navigation';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import mattermostManaged from '@mattermost-managed';
|
||||
import BottomSheet from '@utils/bottom_sheet';
|
||||
import {getDisplayNameForLanguage} from '@utils/markdown';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
const MAX_LINES = 4;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class MarkdownEmoji extends PureComponent {
|
||||
static propTypes = {
|
||||
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
isEdited: PropTypes.bool,
|
||||
shouldRenderJumboEmoji: PropTypes.bool.isRequired,
|
||||
isJumboEmoji: PropTypes.bool.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -44,7 +44,7 @@ export default class MarkdownEmoji extends PureComponent {
|
||||
};
|
||||
|
||||
computeTextStyle = (baseStyle) => {
|
||||
if (!this.props.shouldRenderJumboEmoji) {
|
||||
if (!this.props.isJumboEmoji) {
|
||||
return baseStyle;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('MarkdownEmoji', () => {
|
||||
const baseProps = {
|
||||
baseTextStyle: {color: '#3d3c40', fontSize: 15, lineHeight: 20},
|
||||
isEdited: false,
|
||||
shouldRenderJumboEmoji: true,
|
||||
isJumboEmoji: true,
|
||||
theme: Preferences.THEMES.default,
|
||||
value: ':smile:',
|
||||
};
|
||||
|
||||
@@ -22,7 +22,8 @@ import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import BottomSheet from '@utils/bottom_sheet';
|
||||
import {generateId} from '@utils/file';
|
||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge, openGalleryAtIndex} from '@utils/images';
|
||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {normalizeProtocol, tryOpenURL} from '@utils/url';
|
||||
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {handleSelectChannelByName} from '@actions/views/channel';
|
||||
import {showPermalink} from '@actions/views/permalink';
|
||||
import {getConfig, getCurrentUrl} from '@mm-redux/selectors/entities/general';
|
||||
import {getCurrentTeam} from '@mm-redux/selectors/entities/teams';
|
||||
import {handleSelectChannelByName} from 'app/actions/views/channel';
|
||||
|
||||
import MarkdownLink from './markdown_link';
|
||||
|
||||
@@ -22,6 +23,7 @@ function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
handleSelectChannelByName,
|
||||
showPermalink,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,17 +22,16 @@ export default class MarkdownLink extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
handleSelectChannelByName: PropTypes.func.isRequired,
|
||||
showPermalink: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]),
|
||||
href: PropTypes.string.isRequired,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
serverURL: PropTypes.string,
|
||||
siteURL: PropTypes.string.isRequired,
|
||||
currentTeamName: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onPermalinkPress: () => true,
|
||||
serverURL: '',
|
||||
siteURL: '',
|
||||
};
|
||||
@@ -42,7 +41,9 @@ export default class MarkdownLink extends PureComponent {
|
||||
};
|
||||
|
||||
handlePress = preventDoubleTap(async () => {
|
||||
const {href, onPermalinkPress, serverURL, siteURL} = this.props;
|
||||
const {intl} = this.context;
|
||||
const {actions, currentTeamName, href, serverURL, siteURL} = this.props;
|
||||
const {handleSelectChannelByName, showPermalink} = actions;
|
||||
const url = normalizeProtocol(href);
|
||||
|
||||
if (!url) {
|
||||
@@ -58,14 +59,10 @@ export default class MarkdownLink extends PureComponent {
|
||||
|
||||
if (match) {
|
||||
if (match.type === DeepLinkTypes.CHANNEL) {
|
||||
const {intl} = this.context;
|
||||
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel.bind(null, intl), intl);
|
||||
handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel, intl);
|
||||
} else if (match.type === DeepLinkTypes.PERMALINK) {
|
||||
if (match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
|
||||
onPermalinkPress(match.postId, this.props.currentTeamName);
|
||||
} else {
|
||||
onPermalinkPress(match.postId, match.teamName);
|
||||
}
|
||||
const teamName = match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT ? currentTeamName : match.teamName;
|
||||
showPermalink(intl, teamName, match.postId);
|
||||
}
|
||||
} else {
|
||||
const onError = () => {
|
||||
|
||||
@@ -9,7 +9,8 @@ import CompassIcon from '@components/compass_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import {calculateDimensions, isGifTooLarge, openGalleryAtIndex} from '@utils/images';
|
||||
import {calculateDimensions, isGifTooLarge} from '@utils/images';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/file';
|
||||
|
||||
import type {PostImage} from '@mm-redux/types/posts';
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import Button from 'react-native-button';
|
||||
|
||||
import ActionButtonText from './action_button_text';
|
||||
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {getStatusColors} from '@utils/message_attachment_colors';
|
||||
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {ActionResult} from '@mm-redux/types/actions';
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<ActionResult>;
|
||||
};
|
||||
id: string;
|
||||
name: string;
|
||||
postId: string;
|
||||
theme: Theme,
|
||||
cookie?: string,
|
||||
disabled?: boolean,
|
||||
buttonColor?: string,
|
||||
}
|
||||
export default class ActionButton extends PureComponent<Props> {
|
||||
handleActionPress = preventDoubleTap(() => {
|
||||
const {actions, id, postId, cookie} = this.props;
|
||||
actions.doPostActionWithCookie(postId, id, cookie || '');
|
||||
}, 4000);
|
||||
|
||||
render() {
|
||||
const {name, theme, disabled, buttonColor} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
let customButtonStyle;
|
||||
let customButtonTextStyle;
|
||||
|
||||
if (buttonColor) {
|
||||
const STATUS_COLORS = getStatusColors(theme);
|
||||
const hexColor = STATUS_COLORS[buttonColor] || theme[buttonColor] || buttonColor;
|
||||
customButtonStyle = {borderColor: changeOpacity(hexColor, 0.25), backgroundColor: '#ffffff'};
|
||||
customButtonTextStyle = {color: hexColor};
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
containerStyle={[style.button, customButtonStyle]}
|
||||
disabledContainerStyle={style.buttonDisabled}
|
||||
onPress={this.handleActionPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ActionButtonText
|
||||
message={name}
|
||||
style={{...style.text, ...customButtonTextStyle}}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
const STATUS_COLORS = getStatusColors(theme);
|
||||
return {
|
||||
button: {
|
||||
borderRadius: 4,
|
||||
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
|
||||
borderWidth: 2,
|
||||
opacity: 1,
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
justifyContent: 'center',
|
||||
height: 36,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
|
||||
},
|
||||
text: {
|
||||
color: STATUS_COLORS.default,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
lineHeight: 17,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import ActionButton from './action_button';
|
||||
|
||||
import {doPostActionWithCookie} from '@mm-redux/actions/posts';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {ActionFunc, ActionResult} from '@mm-redux/types/actions';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
return {
|
||||
theme: getTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string | undefined) => Promise<ActionResult>;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
|
||||
doPostActionWithCookie,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ActionButton);
|
||||
@@ -1,55 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AttachmentImage it matches snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"marginTop": 5,
|
||||
},
|
||||
Object {
|
||||
"width": 38,
|
||||
},
|
||||
]
|
||||
}
|
||||
type="none"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.1)",
|
||||
"borderRadius": 2,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
},
|
||||
Object {
|
||||
"height": 28,
|
||||
"width": 28,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Connect(ProgressiveImage)
|
||||
id="123"
|
||||
imageStyle={
|
||||
Object {
|
||||
"marginBottom": 5,
|
||||
"marginLeft": 2.5,
|
||||
"marginRight": 5,
|
||||
"marginTop": 2.5,
|
||||
}
|
||||
}
|
||||
imageUri="https://images.com/image.png"
|
||||
resizeMode="contain"
|
||||
style={
|
||||
Object {
|
||||
"height": 28,
|
||||
"width": 28,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
import AttachmentImage, {State, Props} from './index';
|
||||
|
||||
describe('AttachmentImage', () => {
|
||||
const baseProps = {
|
||||
deviceHeight: 256,
|
||||
deviceWidth: 128,
|
||||
imageMetadata: {width: 32, height: 32},
|
||||
imageUrl: 'https://images.com/image.png',
|
||||
theme: Preferences.THEMES.default,
|
||||
} as Props;
|
||||
|
||||
test('it matches snapshot', () => {
|
||||
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it sets state based on props', () => {
|
||||
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://images.com/image.png');
|
||||
expect(state.originalWidth).toBe(32);
|
||||
});
|
||||
|
||||
test('it does not render image if no imageUrl is provided', () => {
|
||||
const props = {...baseProps};
|
||||
delete props.imageUrl;
|
||||
delete props.imageMetadata;
|
||||
|
||||
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...props}/>);
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(false);
|
||||
expect(state.imageUri).toBe(null);
|
||||
});
|
||||
|
||||
test('it updates image when imageUrl prop changes', () => {
|
||||
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
wrapper.setProps({
|
||||
imageUrl: 'https://someothersite.com/picture.png',
|
||||
imageMetadata: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
},
|
||||
});
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://someothersite.com/picture.png');
|
||||
expect(state.originalWidth).toBe(96);
|
||||
});
|
||||
|
||||
test('it does not update image when an unrelated prop changes', () => {
|
||||
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
wrapper.setProps({
|
||||
theme: {...Preferences.THEMES.default},
|
||||
});
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://images.com/image.png');
|
||||
expect(state.originalWidth).toBe(32);
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {generateId} from '@utils/file';
|
||||
import {isGifTooLarge, openGalleryAtIndex, calculateDimensions} from '@utils/images';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {PostImage} from '@mm-redux/types/posts';
|
||||
import {FileInfo} from '@mm-redux/types/files';
|
||||
|
||||
const VIEWPORT_IMAGE_OFFSET = 100;
|
||||
const VIEWPORT_IMAGE_CONTAINER_OFFSET = 10;
|
||||
|
||||
export type Props = {
|
||||
deviceHeight: number;
|
||||
deviceWidth: number;
|
||||
imageMetadata?: PostImage;
|
||||
imageUrl?: string;
|
||||
postId?: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export type State = {
|
||||
hasImage: boolean;
|
||||
imageUri: string | null;
|
||||
originalHeight?: number;
|
||||
originalWidth?: number;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export default class AttachmentImage extends PureComponent<Props, State> {
|
||||
private fileId: string;
|
||||
private mounted = false;
|
||||
private maxImageWidth = 0;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.fileId = generateId();
|
||||
this.state = {
|
||||
hasImage: Boolean(props.imageUrl),
|
||||
imageUri: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
const {imageUrl, imageMetadata} = this.props;
|
||||
|
||||
this.setViewPortMaxWidth();
|
||||
if (imageMetadata) {
|
||||
this.setImageDimensionsFromMeta(null, imageMetadata);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
this.setImageUrl(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.imageUrl && (prevProps.imageUrl !== this.props.imageUrl)) {
|
||||
this.setImageUrl(this.props.imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
getFileInfo = () => {
|
||||
const {imageUrl, postId} = this.props;
|
||||
const {
|
||||
imageUri: uri,
|
||||
originalHeight,
|
||||
originalWidth,
|
||||
} = this.state;
|
||||
|
||||
if (!imageUrl) {
|
||||
return {
|
||||
id: this.fileId,
|
||||
post_id: postId,
|
||||
uri,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
} as FileInfo;
|
||||
}
|
||||
|
||||
let filename = imageUrl.substring(imageUrl.lastIndexOf('/') + 1, imageUrl.indexOf('?') === -1 ? imageUrl.length : imageUrl.indexOf('?'));
|
||||
const extension = filename.split('.').pop();
|
||||
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
}
|
||||
|
||||
const out = {
|
||||
id: this.fileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: postId,
|
||||
uri,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
} as FileInfo;
|
||||
return out;
|
||||
}
|
||||
|
||||
handlePreviewImage = () => {
|
||||
const files = [this.getFileInfo()];
|
||||
openGalleryAtIndex(0, files);
|
||||
};
|
||||
|
||||
setImageDimensions = (imageUri: string | null, dimensions: {width?: number; height?: number;}, originalWidth: number, originalHeight: number) => {
|
||||
if (this.mounted) {
|
||||
this.setState({
|
||||
...dimensions,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
imageUri,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setImageDimensionsFromMeta = (imageUri: string | null, imageMetadata: PostImage) => {
|
||||
const dimensions = calculateDimensions(imageMetadata.height, imageMetadata.width, this.maxImageWidth);
|
||||
this.setImageDimensions(imageUri, dimensions, imageMetadata.width, imageMetadata.height);
|
||||
};
|
||||
|
||||
setImageUrl = (imageURL: string) => {
|
||||
const {imageMetadata} = this.props;
|
||||
|
||||
if (imageMetadata) {
|
||||
this.setImageDimensionsFromMeta(imageURL, imageMetadata);
|
||||
}
|
||||
};
|
||||
|
||||
setViewPortMaxWidth = () => {
|
||||
const {deviceWidth, deviceHeight} = this.props;
|
||||
const viewPortWidth = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
|
||||
this.maxImageWidth = viewPortWidth - VIEWPORT_IMAGE_OFFSET;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {imageMetadata, theme} = this.props;
|
||||
const {hasImage, height, imageUri, width} = this.state;
|
||||
|
||||
if (!hasImage || isGifTooLarge(imageMetadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
let progressiveImage;
|
||||
if (imageUri) {
|
||||
progressiveImage = (
|
||||
<ProgressiveImage
|
||||
id={this.fileId}
|
||||
imageStyle={style.attachmentMargin}
|
||||
style={{height, width}}
|
||||
imageUri={imageUri}
|
||||
resizeMode='contain'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
progressiveImage = (<View style={{width, height}}/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePreviewImage}
|
||||
style={[style.container, {width: this.maxImageWidth + VIEWPORT_IMAGE_CONTAINER_OFFSET}]}
|
||||
type={'none'}
|
||||
>
|
||||
<View
|
||||
style={[style.imageContainer, {width, height}]}
|
||||
>
|
||||
{progressiveImage}
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginTop: 5,
|
||||
},
|
||||
imageContainer: {
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
|
||||
borderWidth: 1,
|
||||
borderRadius: 2,
|
||||
flex: 1,
|
||||
},
|
||||
attachmentMargin: {
|
||||
marginTop: 2.5,
|
||||
marginLeft: 2.5,
|
||||
marginBottom: 5,
|
||||
marginRight: 5,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {LayoutChangeEvent, ScrollView, StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import ShowMoreButton from '@components/show_more_button';
|
||||
|
||||
import {PostMetadata} from '@mm-redux/types/posts';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
const SHOW_MORE_HEIGHT = 60;
|
||||
|
||||
type Props = {
|
||||
baseTextStyle: StyleProp<TextStyle>,
|
||||
blockStyles?: StyleProp<ViewStyle>[],
|
||||
deviceHeight: number,
|
||||
hasThumbnail?: boolean,
|
||||
metadata?: PostMetadata,
|
||||
onPermalinkPress?: () => void,
|
||||
textStyles?: StyleProp<TextStyle>[],
|
||||
theme?: Theme,
|
||||
value?: string,
|
||||
}
|
||||
|
||||
type State = {
|
||||
collapsed: boolean;
|
||||
isLongText: boolean;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
function getMaxHeight(deviceHeight: number) {
|
||||
return Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
|
||||
}
|
||||
|
||||
export default class AttachmentText extends PureComponent<Props, State> {
|
||||
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
|
||||
const {deviceHeight} = nextProps;
|
||||
const maxHeight = getMaxHeight(deviceHeight);
|
||||
|
||||
if (maxHeight !== prevState.maxHeight) {
|
||||
return {
|
||||
maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const maxHeight = getMaxHeight(props.deviceHeight);
|
||||
this.state = {
|
||||
collapsed: true,
|
||||
isLongText: false,
|
||||
maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
handleLayout = (event: LayoutChangeEvent) => {
|
||||
const {height} = event.nativeEvent.layout;
|
||||
const {maxHeight} = this.state;
|
||||
|
||||
if (height >= maxHeight) {
|
||||
this.setState({
|
||||
isLongText: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapseState = () => {
|
||||
const {collapsed} = this.state;
|
||||
this.setState({collapsed: !collapsed});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
hasThumbnail,
|
||||
metadata,
|
||||
onPermalinkPress,
|
||||
textStyles,
|
||||
theme,
|
||||
value,
|
||||
} = this.props;
|
||||
const {collapsed, isLongText, maxHeight} = this.state;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={hasThumbnail && style.container}>
|
||||
<ScrollView
|
||||
style={{maxHeight: (collapsed ? maxHeight : undefined), overflow: 'hidden'}}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
onLayout={this.handleLayout}
|
||||
removeClippedSubviews={isLongText && collapsed}
|
||||
>
|
||||
<Markdown
|
||||
|
||||
// TODO: remove any when migrating Markdown to typescript
|
||||
baseTextStyle={baseTextStyle as any}
|
||||
textStyles={textStyles}
|
||||
blockStyles={blockStyles}
|
||||
disableGallery={true}
|
||||
imagesMetadata={metadata?.images}
|
||||
value={value}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{isLongText &&
|
||||
<ShowMoreButton
|
||||
onPress={this.toggleCollapseState}
|
||||
showMore={collapsed}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
container: {
|
||||
paddingRight: 60,
|
||||
},
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {Alert, Text, View} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {tryOpenURL} from '@utils/url';
|
||||
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
type Props = {
|
||||
link?: string;
|
||||
theme: Theme;
|
||||
value?: string;
|
||||
}
|
||||
export default class AttachmentTitle extends PureComponent<Props> {
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
openLink = () => {
|
||||
const {link} = this.props;
|
||||
const {intl} = this.context;
|
||||
|
||||
if (link) {
|
||||
const onError = () => {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.text',
|
||||
defaultMessage: 'Unable to open the link.',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
tryOpenURL(link, onError);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
link,
|
||||
value,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
let title;
|
||||
if (link) {
|
||||
title = (
|
||||
<Text
|
||||
style={[style.title, Boolean(link) && style.link]}
|
||||
onPress={this.openLink}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
title = (
|
||||
<Markdown
|
||||
isEdited={false}
|
||||
isReplyPost={false}
|
||||
disableHashtags={true}
|
||||
disableAtMentions={true}
|
||||
disableChannelLink={true}
|
||||
disableGallery={true}
|
||||
autolinkedUrlSchemes={[]}
|
||||
mentionKeys={[]}
|
||||
theme={theme}
|
||||
value={value}
|
||||
baseTextStyle={style.title}
|
||||
textStyles={{link: style.link}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
{title}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginTop: 3,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
fontWeight: '600',
|
||||
marginBottom: 5,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
link: {
|
||||
color: theme.linkColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import {MessageAttachment as MessageAttachmentType} from '@mm-redux/types/message_attachments';
|
||||
import {PostMetadata} from '@mm-redux/types/posts';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
import MessageAttachment from './message_attachment';
|
||||
|
||||
type Props = {
|
||||
attachments: MessageAttachmentType[],
|
||||
baseTextStyle?: StyleProp<TextStyle>,
|
||||
blockStyles?: StyleProp<ViewStyle>[],
|
||||
deviceHeight: number,
|
||||
deviceWidth: number,
|
||||
postId: string,
|
||||
metadata?: PostMetadata,
|
||||
onPermalinkPress?: () => void,
|
||||
theme: Theme,
|
||||
textStyles?: StyleProp<TextStyle>[],
|
||||
}
|
||||
|
||||
export default function MessageAttachments(props: Props) {
|
||||
const {
|
||||
attachments,
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
deviceHeight,
|
||||
deviceWidth,
|
||||
metadata,
|
||||
onPermalinkPress,
|
||||
postId,
|
||||
theme,
|
||||
textStyles,
|
||||
} = props;
|
||||
const content = [] as React.ReactNode[];
|
||||
|
||||
attachments.forEach((attachment, i) => {
|
||||
content.push(
|
||||
<MessageAttachment
|
||||
attachment={attachment}
|
||||
baseTextStyle={baseTextStyle}
|
||||
blockStyles={blockStyles}
|
||||
deviceHeight={deviceHeight}
|
||||
deviceWidth={deviceWidth}
|
||||
key={'att_' + i}
|
||||
metadata={metadata}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
postId={postId}
|
||||
theme={theme}
|
||||
textStyles={textStyles}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{flex: 1, flexDirection: 'column'}}>
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AttachmentFooter matches snapshot 1`] = `
|
||||
<ForwardRef(AnimatedComponentWrapper)
|
||||
pointerEvents="none"
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"height": 38,
|
||||
"position": "absolute",
|
||||
"width": "100%",
|
||||
"zIndex": 9,
|
||||
},
|
||||
Object {
|
||||
"backgroundColor": "rgba(147, 147, 147, 1)",
|
||||
"opacity": 0,
|
||||
"top": 50,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<ForwardRef(AnimatedComponentWrapper)
|
||||
edges={
|
||||
Array [
|
||||
"left",
|
||||
"right",
|
||||
]
|
||||
}
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"height": 38,
|
||||
"paddingLeft": 12,
|
||||
"paddingRight": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedText
|
||||
defaultMessage="Connected"
|
||||
id="mobile.offlineIndicator.connected"
|
||||
style={
|
||||
Object {
|
||||
"color": "#FFFFFF",
|
||||
"flex": 1,
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "flex-end",
|
||||
"height": 24,
|
||||
"justifyContent": "center",
|
||||
"paddingRight": 10,
|
||||
"width": 60,
|
||||
}
|
||||
}
|
||||
>
|
||||
<CompassIcon
|
||||
color="#FFFFFF"
|
||||
name="check"
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
</ForwardRef(AnimatedComponentWrapper)>
|
||||
</ForwardRef(AnimatedComponentWrapper)>
|
||||
`;
|
||||
@@ -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 {stopPeriodicStatusUpdates, startPeriodicStatusUpdates} from '@mm-redux/actions/users';
|
||||
import {init as initWebSocket, close as closeWebSocket} from '@actions/websocket';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
|
||||
import {connection} from 'app/actions/device';
|
||||
import {markChannelViewedAndReadOnReconnect, setChannelRetryFailed} from 'app/actions/views/channel';
|
||||
import {setCurrentUserStatusOffline, logout} from 'app/actions/views/user';
|
||||
import {getConnection, isLandscape} from 'app/selectors/device';
|
||||
|
||||
import NetworkIndicator from './network_indicator';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const {websocket} = state.requests.general;
|
||||
const websocketStatus = websocket.status;
|
||||
|
||||
return {
|
||||
currentChannelId: getCurrentChannelId(state),
|
||||
isLandscape: isLandscape(state),
|
||||
isOnline: getConnection(state),
|
||||
websocketErrorCount: websocket.error,
|
||||
websocketStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
closeWebSocket,
|
||||
connection,
|
||||
initWebSocket,
|
||||
logout,
|
||||
markChannelViewedAndReadOnReconnect,
|
||||
setChannelRetryFailed,
|
||||
setCurrentUserStatusOffline,
|
||||
startPeriodicStatusUpdates,
|
||||
stopPeriodicStatusUpdates,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(NetworkIndicator);
|
||||
36
app/components/network_indicator/index.ts
Normal file
36
app/components/network_indicator/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {stopPeriodicStatusUpdates, startPeriodicStatusUpdates} from '@mm-redux/actions/users';
|
||||
import {init as initWebSocket, close as closeWebSocket} from '@actions/websocket';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
|
||||
import {markChannelViewedAndReadOnReconnect} from 'app/actions/views/channel';
|
||||
import {setCurrentUserStatusOffline} from 'app/actions/views/user';
|
||||
|
||||
import type {GlobalState} from '@mm-redux/types/store';
|
||||
|
||||
import NetworkIndicator from './network';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
const {websocket} = state.requests.general;
|
||||
|
||||
return {
|
||||
channelId: getCurrentChannelId(state),
|
||||
errorCount: websocket.error,
|
||||
status: websocket.status,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
closeWebSocket,
|
||||
initWebSocket,
|
||||
markChannelViewedAndReadOnReconnect,
|
||||
setCurrentUserStatusOffline,
|
||||
startPeriodicStatusUpdates,
|
||||
stopPeriodicStatusUpdates,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(NetworkIndicator);
|
||||
292
app/components/network_indicator/network.tsx
Normal file
292
app/components/network_indicator/network.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useNetInfo} from '@react-native-community/netinfo';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {ActivityIndicator, AppState, AppStateStatus, Platform, StyleSheet, View} from 'react-native';
|
||||
import Animated, {Easing, interpolateColor, runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {ViewTypes} from '@constants';
|
||||
import PushNotifications from '@init/push_notifications';
|
||||
import {debounce} from '@mm-redux/actions/helpers';
|
||||
import {RequestStatus} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {t} from '@utils/i18n';
|
||||
import networkConnectionListener, {checkConnection} from '@utils/network';
|
||||
|
||||
type Props = {
|
||||
channelId?: string;
|
||||
closeWebSocket: (shouldReconnect?: boolean) => void;
|
||||
errorCount: number | null;
|
||||
initWebSocket: (additionalOptions: {forceConnection: boolean}) => void;
|
||||
markChannelViewedAndReadOnReconnect: (channelId: string) => void;
|
||||
setCurrentUserStatusOffline: () => void;
|
||||
startPeriodicStatusUpdates: (forceStatusUpdate: boolean) => void;
|
||||
status: string;
|
||||
stopPeriodicStatusUpdates: () => void;
|
||||
}
|
||||
|
||||
type AppStateCallBack = (appState: AppStateStatus) => Promise<void>;
|
||||
|
||||
type ConnectionChangedEvent = {
|
||||
hasInternet: boolean;
|
||||
serverReachable: boolean
|
||||
};
|
||||
|
||||
const MAX_WEBSOCKET_RETRIES = 3;
|
||||
const CONNECTION_RETRY_SECONDS = 5;
|
||||
const CONNECTION_RETRY_TIMEOUT = 1000 * CONNECTION_RETRY_SECONDS; // 5 seconds
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: ViewTypes.INDICATOR_BAR_HEIGHT,
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
...Platform.select({
|
||||
android: {
|
||||
elevation: 9,
|
||||
},
|
||||
ios: {
|
||||
zIndex: 9,
|
||||
},
|
||||
}),
|
||||
},
|
||||
wrapper: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
height: ViewTypes.INDICATOR_BAR_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 12,
|
||||
paddingRight: 5,
|
||||
},
|
||||
message: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
actionContainer: {
|
||||
alignItems: 'flex-end',
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
paddingRight: 10,
|
||||
width: 60,
|
||||
},
|
||||
});
|
||||
|
||||
const colors = ['#939393', '#629a41'];
|
||||
const stateChange = (callback: AppStateCallBack) => (Platform.OS === 'android' ? callback : debounce(callback, 300));
|
||||
|
||||
// For Gekidou the WS handler should be implemented with events instead.
|
||||
// We should have a central place where we manage the WebSocket connections for each server
|
||||
// and emit events so this component can React to them.
|
||||
const NetworkIndicator = ({
|
||||
channelId, closeWebSocket, errorCount, initWebSocket, markChannelViewedAndReadOnReconnect,
|
||||
setCurrentUserStatusOffline, startPeriodicStatusUpdates, status, stopPeriodicStatusUpdates,
|
||||
}: Props) => {
|
||||
const netinfo = useNetInfo();
|
||||
const firstRun = useRef(true);
|
||||
const clearNotificationTimeout = useRef<NodeJS.Timeout>();
|
||||
const retryTimeout = useRef<NodeJS.Timeout>();
|
||||
const bgColor = useSharedValue(0);
|
||||
const [connected, setConnected] = useState(true);
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
|
||||
let i18nId;
|
||||
let defaultMessage;
|
||||
let action;
|
||||
|
||||
const clearNotifications = () => {
|
||||
if (channelId) {
|
||||
PushNotifications.clearChannelNotifications(channelId);
|
||||
markChannelViewedAndReadOnReconnect(channelId);
|
||||
}
|
||||
};
|
||||
|
||||
const hanleAnimationFinished = () => {
|
||||
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, !connected);
|
||||
};
|
||||
|
||||
const handleConnectionChange = ({hasInternet}: ConnectionChangedEvent) => {
|
||||
if (!firstRun.current) {
|
||||
if (!hasInternet) {
|
||||
setConnected(false);
|
||||
}
|
||||
handleWebSocket(hasInternet);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReconnect = () => {
|
||||
if (retryTimeout.current) {
|
||||
clearTimeout(retryTimeout.current);
|
||||
}
|
||||
|
||||
retryTimeout.current = setTimeout(async () => {
|
||||
if (status !== RequestStatus.STARTED || status !== RequestStatus.SUCCESS) {
|
||||
const {serverReachable} = await checkConnection(netinfo.isInternetReachable);
|
||||
handleWebSocket(serverReachable);
|
||||
|
||||
if (!serverReachable) {
|
||||
handleReconnect();
|
||||
}
|
||||
}
|
||||
}, CONNECTION_RETRY_TIMEOUT);
|
||||
};
|
||||
|
||||
const handleWebSocket = (connect: boolean) => {
|
||||
if (connect) {
|
||||
initWebSocket({forceConnection: true});
|
||||
startPeriodicStatusUpdates(true);
|
||||
} else {
|
||||
closeWebSocket(true);
|
||||
stopPeriodicStatusUpdates();
|
||||
setCurrentUserStatusOffline();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleWebSocket(true);
|
||||
firstRun.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const networkListener = networkConnectionListener(handleConnectionChange);
|
||||
return () => networkListener.removeEventListener();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
handleWebSocket(false);
|
||||
if (retryTimeout.current) {
|
||||
clearTimeout(retryTimeout.current);
|
||||
retryTimeout.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = stateChange(async (appState: AppStateStatus) => {
|
||||
const active = appState === 'active';
|
||||
handleWebSocket(active);
|
||||
|
||||
if (active) {
|
||||
// Clear the notifications for the current channel after one second
|
||||
// this is done so we can cancel it in case the app is brought to the
|
||||
// foreground by tapping a notification from another channel
|
||||
clearNotificationTimeout.current = setTimeout(clearNotifications, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
AppState.addEventListener('change', handleAppStateChange);
|
||||
|
||||
return () => {
|
||||
AppState.removeEventListener('change', handleAppStateChange);
|
||||
};
|
||||
}, [netinfo.isInternetReachable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (clearNotificationTimeout.current) {
|
||||
clearTimeout(clearNotificationTimeout.current);
|
||||
clearNotificationTimeout.current = undefined;
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== RequestStatus.SUCCESS && errorCount! >= 2) {
|
||||
setConnected(false);
|
||||
} else if (status === RequestStatus.SUCCESS) {
|
||||
setConnected(true);
|
||||
}
|
||||
}, [status, errorCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCount! > MAX_WEBSOCKET_RETRIES) {
|
||||
handleWebSocket(false);
|
||||
handleReconnect();
|
||||
}
|
||||
}, [errorCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const navbarChanged = (height: number) => {
|
||||
setTranslateY(height);
|
||||
};
|
||||
|
||||
EventEmitter.on(ViewTypes.CHANNEL_NAV_BAR_CHANGED, navbarChanged);
|
||||
return () => EventEmitter.off(ViewTypes.CHANNEL_NAV_BAR_CHANGED, navbarChanged);
|
||||
}, [translateY]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const onAnimation = (isFinished: boolean) => {
|
||||
if (isFinished) {
|
||||
runOnJS(hanleAnimationFinished)();
|
||||
}
|
||||
};
|
||||
|
||||
bgColor.value = withTiming(connected ? 1 : 0, {duration: 100, easing: Easing.linear});
|
||||
return {
|
||||
backgroundColor: interpolateColor(
|
||||
bgColor.value,
|
||||
[0, 1],
|
||||
colors,
|
||||
),
|
||||
transform: [{translateY: withTiming(connected ? 0 : translateY, {duration: 300}, onAnimation)}],
|
||||
};
|
||||
}, [connected, translateY]);
|
||||
|
||||
if (netinfo.isInternetReachable) {
|
||||
if (connected) {
|
||||
i18nId = t('mobile.offlineIndicator.connected');
|
||||
defaultMessage = 'Connected';
|
||||
action = (
|
||||
<View style={styles.actionContainer}>
|
||||
<CompassIcon
|
||||
color='#FFFFFF'
|
||||
name='check'
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
i18nId = t('mobile.offlineIndicator.connecting');
|
||||
defaultMessage = 'Connecting...';
|
||||
action = (
|
||||
<View style={styles.actionContainer}>
|
||||
<ActivityIndicator
|
||||
color='#FFFFFF'
|
||||
size='small'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
i18nId = t('mobile.offlineIndicator.offline');
|
||||
defaultMessage = 'No internet connection';
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents='none'
|
||||
style={[styles.container, animatedStyle]}
|
||||
>
|
||||
<SafeAreaView
|
||||
edges={['left', 'right']}
|
||||
style={styles.wrapper}
|
||||
>
|
||||
{Boolean(i18nId) &&
|
||||
<FormattedText
|
||||
defaultMessage={defaultMessage}
|
||||
id={i18nId}
|
||||
style={styles.message}
|
||||
testID='network_indicator.message'
|
||||
/>
|
||||
}
|
||||
{action}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkIndicator;
|
||||
@@ -1,432 +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 {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
AppState,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {ViewTypes} from '@constants';
|
||||
import {INDICATOR_BAR_HEIGHT} from '@constants/view';
|
||||
import PushNotifications from '@init/push_notifications';
|
||||
import {debounce} from '@mm-redux/actions/helpers';
|
||||
import {RequestStatus} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {t} from '@utils/i18n';
|
||||
import networkConnectionListener, {checkConnection} from '@utils/network';
|
||||
|
||||
const MAX_WEBSOCKET_RETRIES = 3;
|
||||
const CONNECTION_RETRY_SECONDS = 5;
|
||||
const CONNECTION_RETRY_TIMEOUT = 1000 * CONNECTION_RETRY_SECONDS; // 30 seconds
|
||||
const {
|
||||
ANDROID_TOP_LANDSCAPE,
|
||||
ANDROID_TOP_PORTRAIT,
|
||||
IOS_TOP_LANDSCAPE,
|
||||
IOS_INSETS_TOP_PORTRAIT,
|
||||
} = ViewTypes;
|
||||
|
||||
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
|
||||
|
||||
export default class NetworkIndicator extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
closeWebSocket: PropTypes.func.isRequired,
|
||||
connection: PropTypes.func.isRequired,
|
||||
initWebSocket: PropTypes.func.isRequired,
|
||||
markChannelViewedAndReadOnReconnect: PropTypes.func.isRequired,
|
||||
logout: PropTypes.func.isRequired,
|
||||
setChannelRetryFailed: PropTypes.func.isRequired,
|
||||
setCurrentUserStatusOffline: PropTypes.func.isRequired,
|
||||
startPeriodicStatusUpdates: PropTypes.func.isRequired,
|
||||
stopPeriodicStatusUpdates: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
currentChannelId: PropTypes.string,
|
||||
isLandscape: PropTypes.bool,
|
||||
isOnline: PropTypes.bool,
|
||||
websocketErrorCount: PropTypes.number,
|
||||
websocketStatus: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
isOnline: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const navBarHeight = Platform.select({
|
||||
android: props.isLandscape ? ANDROID_TOP_LANDSCAPE : ANDROID_TOP_PORTRAIT,
|
||||
ios: props.isLandscape ? IOS_TOP_LANDSCAPE : IOS_INSETS_TOP_PORTRAIT,
|
||||
});
|
||||
|
||||
this.state = {
|
||||
opacity: 0,
|
||||
navBarHeight,
|
||||
};
|
||||
|
||||
this.top = new Animated.Value(navBarHeight - INDICATOR_BAR_HEIGHT);
|
||||
this.clearNotificationTimeout = null;
|
||||
|
||||
this.backgroundColor = new Animated.Value(0);
|
||||
this.firstRun = true;
|
||||
this.statusUpdates = false;
|
||||
|
||||
this.networkListener = networkConnectionListener(this.handleConnectionChange);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
|
||||
AppState.addEventListener('change', this.handleAppStateChange);
|
||||
EventEmitter.on(ViewTypes.CHANNEL_NAV_BAR_CHANGED, this.getNavBarHeight);
|
||||
|
||||
// Attempt to connect when this component mounts
|
||||
// if the websocket is already connected it does not try and connect again
|
||||
this.connect(true);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
currentChannelId: prevChannelId,
|
||||
websocketStatus: previousWebsocketStatus,
|
||||
} = prevProps;
|
||||
const {currentChannelId, websocketErrorCount, websocketStatus} = this.props;
|
||||
|
||||
if (currentChannelId !== prevChannelId && this.clearNotificationTimeout) {
|
||||
clearTimeout(this.clearNotificationTimeout);
|
||||
this.clearNotificationTimeout = null;
|
||||
}
|
||||
|
||||
if (prevState.navBarHeight !== this.state.navBarHeight) {
|
||||
const initialTop = websocketErrorCount || previousWebsocketStatus === RequestStatus.FAILURE || previousWebsocketStatus === RequestStatus.NOT_STARTED ? 0 : INDICATOR_BAR_HEIGHT;
|
||||
this.top.setValue(this.state.navBarHeight - initialTop);
|
||||
}
|
||||
|
||||
if (this.props.isOnline) {
|
||||
if (previousWebsocketStatus !== RequestStatus.SUCCESS && websocketStatus === RequestStatus.SUCCESS) {
|
||||
// Show the connected animation only if we had a previous network status
|
||||
this.connected();
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
} else if (previousWebsocketStatus === RequestStatus.STARTED && websocketStatus === RequestStatus.FAILURE && websocketErrorCount > MAX_WEBSOCKET_RETRIES) {
|
||||
this.handleWebSocket(false);
|
||||
this.handleReconnect();
|
||||
} else if (websocketStatus === RequestStatus.FAILURE) {
|
||||
this.show();
|
||||
}
|
||||
} else {
|
||||
this.offline();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const {closeWebSocket, stopPeriodicStatusUpdates} = this.props.actions;
|
||||
this.mounted = false;
|
||||
|
||||
closeWebSocket(false);
|
||||
stopPeriodicStatusUpdates();
|
||||
this.networkListener.removeEventListener();
|
||||
AppState.removeEventListener('change', this.handleAppStateChange);
|
||||
EventEmitter.off(ViewTypes.CHANNEL_NAV_BAR_CHANGED, this.getNavBarHeight);
|
||||
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
this.connectionRetryTimeout = null;
|
||||
}
|
||||
|
||||
connect = (displayBar = false) => {
|
||||
const {connection, startPeriodicStatusUpdates} = this.props.actions;
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
|
||||
NetInfo.fetch().then(async ({isConnected}) => {
|
||||
const {hasInternet, serverReachable} = await checkConnection(isConnected);
|
||||
|
||||
connection(hasInternet);
|
||||
this.hasInternet = hasInternet;
|
||||
this.serverReachable = serverReachable;
|
||||
|
||||
if (serverReachable) {
|
||||
this.statusUpdates = true;
|
||||
this.initializeWebSocket();
|
||||
startPeriodicStatusUpdates();
|
||||
} else {
|
||||
if (displayBar) {
|
||||
this.show();
|
||||
}
|
||||
|
||||
this.handleWebSocket(false);
|
||||
|
||||
if (hasInternet) {
|
||||
// try to reconnect cause we have internet
|
||||
this.handleReconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
connected = () => {
|
||||
if (this.visible) {
|
||||
this.visible = false;
|
||||
Animated.sequence([
|
||||
Animated.timing(
|
||||
this.backgroundColor, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: false,
|
||||
},
|
||||
),
|
||||
Animated.timing(
|
||||
this.top, {
|
||||
toValue: (this.state.navBarHeight - INDICATOR_BAR_HEIGHT),
|
||||
duration: 300,
|
||||
delay: 500,
|
||||
useNativeDriver: false,
|
||||
},
|
||||
),
|
||||
]).start(() => {
|
||||
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, false);
|
||||
this.backgroundColor.setValue(0);
|
||||
this.setState({
|
||||
opacity: 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getNavBarHeight = (navBarHeight) => {
|
||||
this.setState({navBarHeight});
|
||||
};
|
||||
|
||||
handleWebSocket = (open) => {
|
||||
const {actions} = this.props;
|
||||
const {
|
||||
closeWebSocket,
|
||||
startPeriodicStatusUpdates,
|
||||
stopPeriodicStatusUpdates,
|
||||
} = actions;
|
||||
|
||||
if (open) {
|
||||
this.statusUpdates = true;
|
||||
this.initializeWebSocket();
|
||||
startPeriodicStatusUpdates();
|
||||
} else if (this.statusUpdates) {
|
||||
this.statusUpdates = false;
|
||||
closeWebSocket(true);
|
||||
stopPeriodicStatusUpdates();
|
||||
}
|
||||
};
|
||||
|
||||
handleAppStateChange = async (appState) => {
|
||||
this.onStateChange(appState);
|
||||
};
|
||||
|
||||
handleConnectionChange = ({hasInternet, serverReachable}) => {
|
||||
const {connection} = this.props.actions;
|
||||
|
||||
// On first run always initialize the WebSocket
|
||||
// if we have internet connection
|
||||
if (hasInternet && this.firstRun) {
|
||||
this.initializeWebSocket();
|
||||
this.firstRun = false;
|
||||
|
||||
// if the state of the internet connection was previously known to be false,
|
||||
// don't exit connection handler in order for application to register it has
|
||||
// reconnected to the internet
|
||||
if (this.hasInternet !== false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent for being called more than once.
|
||||
if (this.hasInternet !== hasInternet) {
|
||||
this.hasInternet = hasInternet;
|
||||
connection(hasInternet);
|
||||
}
|
||||
|
||||
if (this.serverReachable !== serverReachable) {
|
||||
this.serverReachable = serverReachable;
|
||||
this.handleWebSocket(serverReachable);
|
||||
}
|
||||
};
|
||||
|
||||
handleReconnect = () => {
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
this.connectionRetryTimeout = setTimeout(() => {
|
||||
const {websocketStatus} = this.props;
|
||||
if (websocketStatus !== RequestStatus.STARTED || websocketStatus !== RequestStatus.SUCCESS) {
|
||||
this.connect();
|
||||
}
|
||||
}, CONNECTION_RETRY_TIMEOUT);
|
||||
};
|
||||
|
||||
initializeWebSocket = async () => {
|
||||
const {actions} = this.props;
|
||||
const {closeWebSocket, initWebSocket} = actions;
|
||||
|
||||
initWebSocket({forceConnection: true}).catch(() => {
|
||||
// we should dispatch a failure and show the app as disconnected
|
||||
closeWebSocket(true);
|
||||
});
|
||||
};
|
||||
|
||||
offline = () => {
|
||||
if (this.connectionRetryTimeout) {
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
}
|
||||
|
||||
this.show();
|
||||
};
|
||||
|
||||
onStateChange = debounce((appState) => {
|
||||
const {actions, currentChannelId} = this.props;
|
||||
const active = appState === 'active';
|
||||
|
||||
if (active) {
|
||||
this.connect(true);
|
||||
|
||||
if (currentChannelId) {
|
||||
// Clear the notifications for the current channel after one second
|
||||
// this is done so we can cancel it in case the app is brought to the
|
||||
// foreground by tapping a notification from another channel
|
||||
this.clearNotificationTimeout = setTimeout(() => {
|
||||
PushNotifications.clearChannelNotifications(currentChannelId);
|
||||
actions.markChannelViewedAndReadOnReconnect(currentChannelId);
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
this.handleWebSocket(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
show = () => {
|
||||
if (!this.visible) {
|
||||
this.visible = true;
|
||||
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, true);
|
||||
this.setState({
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
Animated.timing(
|
||||
this.top, {
|
||||
toValue: this.state.navBarHeight,
|
||||
duration: 300,
|
||||
useNativeDriver: false,
|
||||
},
|
||||
).start(() => {
|
||||
this.props.actions.setCurrentUserStatusOffline();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {isOnline, websocketStatus} = this.props;
|
||||
const background = this.backgroundColor.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['#939393', '#629a41'],
|
||||
});
|
||||
|
||||
let i18nId;
|
||||
let defaultMessage;
|
||||
let action;
|
||||
|
||||
if (isOnline) {
|
||||
switch (websocketStatus) {
|
||||
case RequestStatus.NOT_STARTED:
|
||||
case RequestStatus.FAILURE:
|
||||
case RequestStatus.STARTED:
|
||||
i18nId = t('mobile.offlineIndicator.connecting');
|
||||
defaultMessage = 'Connecting...';
|
||||
action = (
|
||||
<View style={styles.actionContainer}>
|
||||
<ActivityIndicator
|
||||
color='#FFFFFF'
|
||||
size='small'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
break;
|
||||
case RequestStatus.SUCCESS:
|
||||
default:
|
||||
i18nId = t('mobile.offlineIndicator.connected');
|
||||
defaultMessage = 'Connected';
|
||||
action = (
|
||||
<View style={styles.actionContainer}>
|
||||
<CompassIcon
|
||||
color='#FFFFFF'
|
||||
name='check'
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
i18nId = t('mobile.offlineIndicator.offline');
|
||||
defaultMessage = 'No internet connection';
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents='none'
|
||||
style={[styles.container, {top: this.top, backgroundColor: background, opacity: this.state.opacity}]}
|
||||
>
|
||||
<AnimatedSafeAreaView
|
||||
edges={['left', 'right']}
|
||||
style={styles.wrapper}
|
||||
>
|
||||
<FormattedText
|
||||
defaultMessage={defaultMessage}
|
||||
id={i18nId}
|
||||
style={styles.message}
|
||||
/>
|
||||
{action}
|
||||
</AnimatedSafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: INDICATOR_BAR_HEIGHT,
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
...Platform.select({
|
||||
android: {
|
||||
elevation: 9,
|
||||
},
|
||||
ios: {
|
||||
zIndex: 9,
|
||||
},
|
||||
}),
|
||||
},
|
||||
wrapper: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
height: INDICATOR_BAR_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 12,
|
||||
paddingRight: 5,
|
||||
},
|
||||
message: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
actionContainer: {
|
||||
alignItems: 'flex-end',
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
paddingRight: 10,
|
||||
width: 60,
|
||||
},
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Animated} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import {ViewTypes} from '@constants';
|
||||
|
||||
import NetworkIndicator from './network_indicator';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('AttachmentFooter', () => {
|
||||
Animated.sequence = jest.fn(() => ({
|
||||
start: jest.fn((cb) => cb()),
|
||||
}));
|
||||
Animated.timing = jest.fn(() => ({
|
||||
start: jest.fn((cb) => cb()),
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
actions: {
|
||||
closeWebSocket: jest.fn(),
|
||||
connection: jest.fn(),
|
||||
initWebSocket: jest.fn(),
|
||||
markChannelViewedAndReadOnReconnect: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setChannelRetryFailed: jest.fn(),
|
||||
setCurrentUserStatusOffline: jest.fn(),
|
||||
startPeriodicStatusUpdates: jest.fn(),
|
||||
stopPeriodicStatusUpdates: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
it('matches snapshot', () => {
|
||||
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('show', () => {
|
||||
EventEmitter.emit = jest.fn();
|
||||
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
it('emits INDICATOR_BAR_VISIBLE with true only if not already visible', async () => {
|
||||
instance.visible = true;
|
||||
instance.show();
|
||||
expect(EventEmitter.emit).not.toHaveBeenCalled();
|
||||
|
||||
instance.visible = false;
|
||||
instance.show();
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, true);
|
||||
expect(instance.visible).toBe(true);
|
||||
expect(wrapper.state('opacity')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connected', () => {
|
||||
EventEmitter.emit = jest.fn();
|
||||
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
it('emits INDICATOR_BAR_VISIBLE with false only if visible', async () => {
|
||||
instance.visible = false;
|
||||
instance.connected();
|
||||
expect(EventEmitter.emit).not.toHaveBeenCalled();
|
||||
|
||||
instance.visible = true;
|
||||
instance.connected();
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, false);
|
||||
expect(instance.visible).toBe(false);
|
||||
expect(wrapper.state('opacity')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,105 +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 {createPost, removePost} from '@mm-redux/actions/posts';
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
import {isChannelReadOnlyById} from '@mm-redux/selectors/entities/channels';
|
||||
import {getPost, makeGetCommentCountForPost, makeIsPostCommentMention} from '@mm-redux/selectors/entities/posts';
|
||||
import {getUser, getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {getMyPreferences, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {isDateLine, isStartOfNewMessages} from '@mm-redux/utils/post_list';
|
||||
import {isPostFlagged, isSystemMessage} from '@mm-redux/utils/post_utils';
|
||||
|
||||
import {insertToDraft, setPostTooltipVisible} from 'app/actions/views/channel';
|
||||
|
||||
import Post from './post';
|
||||
|
||||
function isConsecutivePost(post, previousPost) {
|
||||
let consecutivePost = false;
|
||||
|
||||
if (post && previousPost) {
|
||||
const postFromWebhook = Boolean(post?.props?.from_webhook); // eslint-disable-line camelcase
|
||||
const prevPostFromWebhook = Boolean(previousPost?.props?.from_webhook); // eslint-disable-line camelcase
|
||||
if (previousPost && previousPost.user_id === post.user_id &&
|
||||
post.create_at - previousPost.create_at <= Posts.POST_COLLAPSE_TIMEOUT &&
|
||||
!postFromWebhook && !prevPostFromWebhook &&
|
||||
!isSystemMessage(post) && !isSystemMessage(previousPost) &&
|
||||
(previousPost.root_id === post.root_id || previousPost.id === post.root_id)) {
|
||||
// The last post and this post were made by the same user within some time
|
||||
consecutivePost = true;
|
||||
}
|
||||
}
|
||||
return consecutivePost;
|
||||
}
|
||||
|
||||
function makeMapStateToProps() {
|
||||
const getCommentCountForPost = makeGetCommentCountForPost();
|
||||
const isPostCommentMention = makeIsPostCommentMention();
|
||||
return function mapStateToProps(state, ownProps) {
|
||||
const post = ownProps.post || getPost(state, ownProps.postId);
|
||||
const previousPostId = (isStartOfNewMessages(ownProps.previousPostId) || isDateLine(ownProps.previousPostId)) ? ownProps.beforePrevPostId : ownProps.previousPostId;
|
||||
const previousPost = getPost(state, previousPostId);
|
||||
const beforePrevPost = getPost(state, ownProps.beforePrevPostId);
|
||||
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const user = getUser(state, post.user_id);
|
||||
const isCommentMention = isPostCommentMention(state, post.id);
|
||||
let isFirstReply = true;
|
||||
let isLastReply = true;
|
||||
let commentedOnPost = null;
|
||||
|
||||
if (ownProps.renderReplies && post && post.root_id) {
|
||||
if (previousPostId) {
|
||||
if (previousPost && (previousPost.id === post.root_id || previousPost.root_id === post.root_id)) {
|
||||
// Previous post is root post or previous post is in same thread
|
||||
isFirstReply = false;
|
||||
} else {
|
||||
// Last post is not a comment on the same message
|
||||
commentedOnPost = getPost(state, post.root_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (ownProps.nextPostId) {
|
||||
const nextPost = getPost(state, ownProps.nextPostId);
|
||||
|
||||
if (nextPost && nextPost.root_id === post.root_id) {
|
||||
isLastReply = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelIsReadOnly: isChannelReadOnlyById(state, post.channel_id),
|
||||
currentUserId,
|
||||
post,
|
||||
isBot: (user ? user.is_bot : false),
|
||||
isFirstReply,
|
||||
isLastReply,
|
||||
consecutivePost: isConsecutivePost(post, previousPost),
|
||||
hasComments: getCommentCountForPost(state, {post}) > 0,
|
||||
commentedOnPost,
|
||||
theme: getTheme(state),
|
||||
isFlagged: isPostFlagged(post.id, myPreferences),
|
||||
isCommentMention,
|
||||
previousPostExists: Boolean(previousPost),
|
||||
beforePrevPostUserId: (beforePrevPost ? beforePrevPost.user_id : null),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
createPost,
|
||||
removePost,
|
||||
setPostTooltipVisible,
|
||||
insertToDraft,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(Post);
|
||||
@@ -1,452 +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 {
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
ViewPropTypes,
|
||||
} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from '@mm-redux/utils/post_utils';
|
||||
|
||||
import {showModalOverCurrentContext, showModal} from '@actions/navigation';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import PostBody from '@components/post_body';
|
||||
import PostHeader from '@components/post_header';
|
||||
import PostProfilePicture from '@components/post_profile_picture';
|
||||
import PostPreHeader from '@components/post_header/post_pre_header';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
|
||||
import {NavigationTypes} from '@constants';
|
||||
|
||||
import {t} from '@utils/i18n';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {fromAutoResponder} from '@utils/general';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
export default class Post extends PureComponent {
|
||||
static propTypes = {
|
||||
testID: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
createPost: PropTypes.func.isRequired,
|
||||
insertToDraft: PropTypes.func.isRequired,
|
||||
removePost: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
channelIsReadOnly: PropTypes.bool,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
highlight: PropTypes.bool,
|
||||
style: ViewPropTypes.style,
|
||||
post: PropTypes.object,
|
||||
renderReplies: PropTypes.bool,
|
||||
isFirstReply: PropTypes.bool,
|
||||
isLastReply: PropTypes.bool,
|
||||
isLastPost: PropTypes.bool,
|
||||
consecutivePost: PropTypes.bool,
|
||||
hasComments: PropTypes.bool,
|
||||
isSearchResult: PropTypes.bool,
|
||||
commentedOnPost: PropTypes.object,
|
||||
managedConfig: PropTypes.object,
|
||||
onHashtagPress: PropTypes.func,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
shouldRenderReplyButton: PropTypes.bool,
|
||||
showAddReaction: PropTypes.bool,
|
||||
showFullDate: PropTypes.bool,
|
||||
showLongPost: PropTypes.bool,
|
||||
theme: PropTypes.object.isRequired,
|
||||
onPress: PropTypes.func,
|
||||
onReply: PropTypes.func,
|
||||
isFlagged: PropTypes.bool,
|
||||
highlightPinnedOrFlagged: PropTypes.bool,
|
||||
skipFlaggedHeader: PropTypes.bool,
|
||||
skipPinnedHeader: PropTypes.bool,
|
||||
isCommentMention: PropTypes.bool,
|
||||
location: PropTypes.string,
|
||||
isBot: PropTypes.bool,
|
||||
previousPostExists: PropTypes.bool,
|
||||
beforePrevPostUserId: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
isSearchResult: false,
|
||||
showAddReaction: true,
|
||||
showLongPost: false,
|
||||
channelIsReadOnly: false,
|
||||
highlightPinnedOrFlagged: true,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.postBodyRef = React.createRef();
|
||||
}
|
||||
|
||||
goToUserProfile = async () => {
|
||||
const {intl} = this.context;
|
||||
const {post, theme} = this.props;
|
||||
const screen = 'UserProfile';
|
||||
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
|
||||
const passProps = {
|
||||
userId: post.user_id,
|
||||
};
|
||||
|
||||
if (!this.closeButton) {
|
||||
this.closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
|
||||
}
|
||||
|
||||
const options = {
|
||||
topBar: {
|
||||
leftButtons: [{
|
||||
id: 'close-settings',
|
||||
icon: this.closeButton,
|
||||
testID: 'close.settings.button',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
Keyboard.dismiss();
|
||||
showModal(screen, title, passProps, options);
|
||||
};
|
||||
|
||||
autofillUserMention = (username) => {
|
||||
this.props.actions.insertToDraft(`@${username} `);
|
||||
};
|
||||
|
||||
handleFailedPostPress = () => {
|
||||
const screen = 'OptionsModal';
|
||||
const passProps = {
|
||||
title: {
|
||||
id: t('mobile.post.failed_title'),
|
||||
defaultMessage: 'Unable to send your message:',
|
||||
},
|
||||
items: [{
|
||||
action: () => {
|
||||
const {failed, id, ...post} = this.props.post; // eslint-disable-line
|
||||
|
||||
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
|
||||
this.props.actions.createPost(post);
|
||||
},
|
||||
text: {
|
||||
id: t('mobile.post.failed_retry'),
|
||||
defaultMessage: 'Try Again',
|
||||
},
|
||||
}, {
|
||||
action: () => {
|
||||
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
|
||||
this.onRemovePost(this.props.post);
|
||||
},
|
||||
text: {
|
||||
id: t('mobile.post.failed_delete'),
|
||||
defaultMessage: 'Delete Message',
|
||||
},
|
||||
textStyle: {
|
||||
color: '#CC3239',
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
showModalOverCurrentContext(screen, passProps);
|
||||
};
|
||||
|
||||
handlePress = preventDoubleTap(() => {
|
||||
this.onPressDetected = true;
|
||||
const {
|
||||
onPress,
|
||||
post,
|
||||
showLongPost,
|
||||
} = this.props;
|
||||
|
||||
const isValidSystemMessage = fromAutoResponder(post) || !isSystemMessage(post);
|
||||
if (onPress && post.state !== Posts.POST_DELETED && isValidSystemMessage && !isPostPendingOrFailed(post)) {
|
||||
onPress(post);
|
||||
} else if ((isPostEphemeral(post) || post.state === Posts.POST_DELETED) && !showLongPost) {
|
||||
this.onRemovePost(post);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.onPressDetected = false;
|
||||
}, 300);
|
||||
});
|
||||
|
||||
handleReply = preventDoubleTap(() => {
|
||||
const {post, onReply} = this.props;
|
||||
if (onReply) {
|
||||
return onReply(post);
|
||||
}
|
||||
|
||||
return this.handlePress();
|
||||
});
|
||||
|
||||
onRemovePost = (post) => {
|
||||
const {removePost} = this.props.actions;
|
||||
removePost(post);
|
||||
};
|
||||
|
||||
isReplyPost = () => {
|
||||
const {renderReplies, post} = this.props;
|
||||
return Boolean(renderReplies && post.root_id && (!isPostEphemeral(post) || post.state === Posts.POST_DELETED));
|
||||
};
|
||||
|
||||
replyBarStyle = () => {
|
||||
const {
|
||||
commentedOnPost,
|
||||
isFirstReply,
|
||||
isLastReply,
|
||||
theme,
|
||||
isCommentMention,
|
||||
} = this.props;
|
||||
|
||||
if (!this.isReplyPost()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
const replyBarStyle = [style.replyBar];
|
||||
|
||||
if (isFirstReply || commentedOnPost) {
|
||||
replyBarStyle.push(style.replyBarFirst);
|
||||
}
|
||||
|
||||
if (isLastReply) {
|
||||
replyBarStyle.push(style.replyBarLast);
|
||||
}
|
||||
|
||||
if (isCommentMention) {
|
||||
replyBarStyle.push(style.commentMentionBgColor);
|
||||
}
|
||||
|
||||
return replyBarStyle;
|
||||
};
|
||||
|
||||
viewUserProfile = preventDoubleTap(() => {
|
||||
this.goToUserProfile();
|
||||
});
|
||||
|
||||
showPostOptions = () => {
|
||||
if (this.postBodyRef?.current && !this.onPressDetected) {
|
||||
this.postBodyRef.current.showPostOptions();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
testID,
|
||||
channelIsReadOnly,
|
||||
commentedOnPost,
|
||||
highlight,
|
||||
isLastPost,
|
||||
isLastReply,
|
||||
isSearchResult,
|
||||
onHashtagPress,
|
||||
onPermalinkPress,
|
||||
post,
|
||||
isBot,
|
||||
renderReplies,
|
||||
shouldRenderReplyButton,
|
||||
showAddReaction,
|
||||
showFullDate,
|
||||
showLongPost,
|
||||
theme,
|
||||
managedConfig,
|
||||
consecutivePost,
|
||||
hasComments,
|
||||
isFlagged,
|
||||
highlightPinnedOrFlagged,
|
||||
skipFlaggedHeader,
|
||||
skipPinnedHeader,
|
||||
location,
|
||||
previousPostExists,
|
||||
beforePrevPostUserId,
|
||||
} = this.props;
|
||||
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
const isReplyPost = this.isReplyPost();
|
||||
const mergeMessage = consecutivePost && !hasComments && !isBot;
|
||||
const highlightFlagged = isFlagged && !skipFlaggedHeader;
|
||||
const hightlightPinned = post.is_pinned && !skipPinnedHeader;
|
||||
|
||||
let highlighted;
|
||||
if (highlight) {
|
||||
highlighted = style.highlight;
|
||||
} else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrFlagged) {
|
||||
highlighted = style.highlightPinnedOrFlagged;
|
||||
}
|
||||
|
||||
let postHeader;
|
||||
let userProfile;
|
||||
let consecutiveStyle;
|
||||
|
||||
if (mergeMessage) {
|
||||
consecutiveStyle = {marginTop: 0};
|
||||
userProfile = <View style={style.consecutivePostContainer}/>;
|
||||
} else {
|
||||
userProfile = (
|
||||
<View style={[style.profilePictureContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
|
||||
<PostProfilePicture
|
||||
onViewUserProfile={this.viewUserProfile}
|
||||
post={post}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
postHeader = (
|
||||
<PostHeader
|
||||
post={post}
|
||||
commentedOnPost={commentedOnPost}
|
||||
createAt={post.create_at}
|
||||
isSearchResult={isSearchResult}
|
||||
shouldRenderReplyButton={shouldRenderReplyButton}
|
||||
showFullDate={showFullDate}
|
||||
onPress={this.handleReply}
|
||||
onUsernamePress={this.viewUserProfile}
|
||||
renderReplies={renderReplies}
|
||||
theme={theme}
|
||||
previousPostExists={previousPostExists}
|
||||
beforePrevPostUserId={beforePrevPostUserId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const replyBarStyle = this.replyBarStyle();
|
||||
const rightColumnStyle = [style.rightColumn, (commentedOnPost && isLastReply && style.rightColumnPadding)];
|
||||
const itemTestID = `${testID}.${post.id}`;
|
||||
|
||||
return (
|
||||
<View
|
||||
testID={testID}
|
||||
style={[style.postStyle, highlighted]}
|
||||
>
|
||||
<TouchableWithFeedback
|
||||
testID={itemTestID}
|
||||
onPress={this.handlePress}
|
||||
onLongPress={this.showPostOptions}
|
||||
delayLongPress={200}
|
||||
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
|
||||
cancelTouchOnPanning={true}
|
||||
>
|
||||
<>
|
||||
<PostPreHeader
|
||||
isConsecutive={mergeMessage}
|
||||
isFlagged={isFlagged}
|
||||
isPinned={post.is_pinned}
|
||||
rightColumnStyle={style.preHeaderRightColumn}
|
||||
skipFlaggedHeader={skipFlaggedHeader}
|
||||
skipPinnedHeader={skipPinnedHeader}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={[style.container, this.props.style, consecutiveStyle]}>
|
||||
{userProfile}
|
||||
<View style={rightColumnStyle}>
|
||||
{postHeader}
|
||||
<PostBody
|
||||
ref={this.postBodyRef}
|
||||
highlight={highlight}
|
||||
channelIsReadOnly={channelIsReadOnly}
|
||||
isLastPost={isLastPost}
|
||||
isSearchResult={isSearchResult}
|
||||
onFailedPostPress={this.handleFailedPostPress}
|
||||
onHashtagPress={onHashtagPress}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
onPress={this.handlePress}
|
||||
post={post}
|
||||
replyBarStyle={replyBarStyle}
|
||||
managedConfig={managedConfig}
|
||||
isFlagged={isFlagged}
|
||||
isReplyPost={isReplyPost}
|
||||
showAddReaction={showAddReaction}
|
||||
showLongPost={showLongPost}
|
||||
location={location}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
postStyle: {
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
pendingPost: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
preHeaderRightColumn: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
marginLeft: 2,
|
||||
},
|
||||
rightColumn: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
marginRight: 12,
|
||||
},
|
||||
rightColumnPadding: {
|
||||
paddingBottom: 3,
|
||||
},
|
||||
consecutivePostContainer: {
|
||||
marginBottom: 10,
|
||||
marginRight: 10,
|
||||
marginLeft: 47,
|
||||
marginTop: 10,
|
||||
},
|
||||
profilePictureContainer: {
|
||||
marginBottom: 5,
|
||||
marginLeft: 12,
|
||||
marginTop: 10,
|
||||
|
||||
// to compensate STATUS_BUFFER in profile_picture component
|
||||
...Platform.select({
|
||||
android: {
|
||||
marginRight: 11,
|
||||
},
|
||||
ios: {
|
||||
marginRight: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
replyBar: {
|
||||
backgroundColor: theme.centerChannelColor,
|
||||
opacity: 0.1,
|
||||
marginLeft: 1,
|
||||
marginRight: 7,
|
||||
width: 3,
|
||||
flexBasis: 3,
|
||||
},
|
||||
replyBarFirst: {
|
||||
paddingTop: 10,
|
||||
},
|
||||
replyBarLast: {
|
||||
paddingBottom: 10,
|
||||
},
|
||||
commentMentionBgColor: {
|
||||
backgroundColor: theme.mentionHighlightBg,
|
||||
opacity: 1,
|
||||
},
|
||||
highlight: {
|
||||
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5),
|
||||
},
|
||||
highlightPinnedOrFlagged: {
|
||||
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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 '@mm-redux/actions/channels';
|
||||
import {removePost} from '@mm-redux/actions/posts';
|
||||
|
||||
import {getPost} from '@mm-redux/selectors/entities/posts';
|
||||
import {getChannel} from '@mm-redux/selectors/entities/channels';
|
||||
import {getCurrentUser} from '@mm-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);
|
||||
@@ -1,224 +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 AtMention from '@components/at_mention';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import {t} from '@utils/i18n';
|
||||
import {concatStyles} from '@utils/theme';
|
||||
|
||||
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,
|
||||
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
currentUser: PropTypes.object.isRequired,
|
||||
channelType: PropTypes.string,
|
||||
post: PropTypes.object.isRequired,
|
||||
postId: PropTypes.string.isRequired,
|
||||
userIds: PropTypes.array.isRequired,
|
||||
usernames: PropTypes.array.isRequired,
|
||||
noGroupsUsernames: PropTypes.array,
|
||||
onPostPress: PropTypes.func,
|
||||
textStyles: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
usernames: [],
|
||||
};
|
||||
|
||||
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 = [], textStyles) {
|
||||
if (usernames.length === 1) {
|
||||
return (
|
||||
<AtMention
|
||||
mentionStyle={this.props.textStyles.mention}
|
||||
mentionName={usernames[0]}
|
||||
onPostPress={this.props.onPostPress}
|
||||
/>
|
||||
);
|
||||
} 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 '}
|
||||
style={textStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function commaSeparator(key) {
|
||||
return <Text key={key}>{', '}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{
|
||||
usernames.map((username) => {
|
||||
return (
|
||||
<AtMention
|
||||
key={username}
|
||||
mentionStyle={this.props.textStyles.mention}
|
||||
mentionName={username}
|
||||
onPostPress={this.props.onPostPress}
|
||||
/>
|
||||
);
|
||||
}).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, baseTextStyle, postId, usernames, noGroupsUsernames} = this.props;
|
||||
|
||||
if (!postId || !channelType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let linkId;
|
||||
let linkText;
|
||||
if (channelType === General.PRIVATE_CHANNEL) {
|
||||
linkId = t('post_body.check_for_out_of_channel_mentions.link.private');
|
||||
linkText = 'add them to this private channel';
|
||||
} else if (channelType === General.OPEN_CHANNEL) {
|
||||
linkId = t('post_body.check_for_out_of_channel_mentions.link.public');
|
||||
linkText = 'add them to the channel';
|
||||
}
|
||||
|
||||
let outOfChannelMessageID;
|
||||
let outOfChannelMessageText;
|
||||
const outOfChannelAtMentions = this.generateAtMentions(usernames, baseTextStyle);
|
||||
if (usernames.length === 1) {
|
||||
outOfChannelMessageID = t('post_body.check_for_out_of_channel_mentions.message.one');
|
||||
outOfChannelMessageText = 'was mentioned but is not in the channel. Would you like to ';
|
||||
} else if (usernames.length > 1) {
|
||||
outOfChannelMessageID = t('post_body.check_for_out_of_channel_mentions.message.multiple');
|
||||
outOfChannelMessageText = 'were mentioned but they are not in the channel. Would you like to ';
|
||||
}
|
||||
|
||||
let outOfGroupsMessageID;
|
||||
let outOfGroupsMessageText;
|
||||
const outOfGroupsAtMentions = this.generateAtMentions(noGroupsUsernames, baseTextStyle);
|
||||
if (noGroupsUsernames?.length) {
|
||||
outOfGroupsMessageID = t('post_body.check_for_out_of_channel_groups_mentions.message');
|
||||
outOfGroupsMessageText = 'did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.';
|
||||
}
|
||||
|
||||
let outOfChannelMessage = null;
|
||||
if (usernames.length) {
|
||||
outOfChannelMessage = (
|
||||
<Text>
|
||||
{outOfChannelAtMentions}
|
||||
{' '}
|
||||
<FormattedText
|
||||
id={outOfChannelMessageID}
|
||||
defaultMessage={outOfChannelMessageText}
|
||||
style={baseTextStyle}
|
||||
/>
|
||||
<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.'}
|
||||
style={baseTextStyle}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
let outOfGroupsMessage = null;
|
||||
if (noGroupsUsernames?.length) {
|
||||
outOfGroupsMessage = (
|
||||
<Text>
|
||||
{outOfGroupsAtMentions}
|
||||
{' '}
|
||||
<FormattedText
|
||||
id={outOfGroupsMessageID}
|
||||
defaultMessage={outOfGroupsMessageText}
|
||||
style={baseTextStyle}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{outOfChannelMessage}
|
||||
{outOfGroupsMessage}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PostAttachmentImage should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"justifyContent": "flex-start",
|
||||
"marginBottom": 6,
|
||||
"marginTop": 10,
|
||||
},
|
||||
Object {
|
||||
"height": 100,
|
||||
},
|
||||
]
|
||||
}
|
||||
type="none"
|
||||
>
|
||||
<View>
|
||||
<Connect(ProgressiveImage)
|
||||
imageUri="uri"
|
||||
onError={[MockFunction]}
|
||||
resizeMode="contain"
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"borderRadius": 3,
|
||||
"justifyContent": "center",
|
||||
"marginVertical": 1,
|
||||
},
|
||||
Object {
|
||||
"height": 100,
|
||||
"width": 100,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
@@ -1,71 +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 {StyleSheet, View} from 'react-native';
|
||||
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {isGifTooLarge} from '@utils/images';
|
||||
|
||||
export default class PostAttachmentImage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
height: PropTypes.number.isRequired,
|
||||
id: PropTypes.string,
|
||||
imageMetadata: PropTypes.object,
|
||||
onError: PropTypes.func.isRequired,
|
||||
onImagePress: PropTypes.func.isRequired,
|
||||
uri: PropTypes.string,
|
||||
width: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
frameCount: 0,
|
||||
};
|
||||
|
||||
handlePress = () => {
|
||||
this.props.onImagePress();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (isGifTooLarge(this.props.imageMetadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note that TouchableWithoutFeedback only works if its child is a View
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePress}
|
||||
style={[styles.imageContainer, {height: this.props.height}]}
|
||||
type={'none'}
|
||||
>
|
||||
<View>
|
||||
<ProgressiveImage
|
||||
id={this.props.id}
|
||||
style={[styles.image, {width: this.props.width, height: this.props.height}]}
|
||||
imageUri={this.props.uri}
|
||||
resizeMode='contain'
|
||||
onError={this.props.onError}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
imageContainer: {
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: 6,
|
||||
marginTop: 10,
|
||||
},
|
||||
image: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 3,
|
||||
justifyContent: 'center',
|
||||
marginVertical: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import PostAttachmentImage from './index';
|
||||
|
||||
describe('PostAttachmentImage', () => {
|
||||
const baseProps = {
|
||||
height: 100,
|
||||
width: 100,
|
||||
onError: jest.fn(),
|
||||
onImagePress: jest.fn(),
|
||||
uri: 'uri',
|
||||
};
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const wrapper = shallow(<PostAttachmentImage {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,374 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match snapshot, without image and description 1`] = `null`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match snapshot, without image and description 2`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={1}
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.5)",
|
||||
"fontSize": 12,
|
||||
"marginBottom": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
Mattermost
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={3}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#2389d7",
|
||||
"fontSize": 14,
|
||||
"marginBottom": 10,
|
||||
},
|
||||
Object {
|
||||
"marginRight": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Title
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"marginTop": 5,
|
||||
},
|
||||
Object {
|
||||
"height": 69.83261802575107,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
type="none"
|
||||
>
|
||||
<FastImage
|
||||
nativeID="image-123"
|
||||
resizeMode="contain"
|
||||
source={
|
||||
Object {
|
||||
"uri": "https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png",
|
||||
}
|
||||
}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"borderRadius": 3,
|
||||
},
|
||||
Object {
|
||||
"height": 69.83261802575107,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match snapshot, without site_name 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={3}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#2389d7",
|
||||
"fontSize": 14,
|
||||
"marginBottom": 10,
|
||||
},
|
||||
Object {
|
||||
"marginRight": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Title
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match snapshot, without title and url 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={3}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#2389d7",
|
||||
"fontSize": 14,
|
||||
"marginBottom": 10,
|
||||
},
|
||||
Object {
|
||||
"marginRight": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
https://mattermost.com/
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderDescription 1`] = `null`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderDescription 2`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={5}
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.7)",
|
||||
"fontSize": 13,
|
||||
"marginBottom": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
Description
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 1`] = `null`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 2`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"marginTop": 5,
|
||||
},
|
||||
Object {
|
||||
"height": 112.56666666666666,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
type="none"
|
||||
>
|
||||
<FastImage
|
||||
nativeID="image-123"
|
||||
resizeMode="contain"
|
||||
source={
|
||||
Object {
|
||||
"uri": "https://mattermost.com/logo.png",
|
||||
}
|
||||
}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"borderRadius": 3,
|
||||
},
|
||||
Object {
|
||||
"height": 112.56666666666666,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 3`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={1}
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.5)",
|
||||
"fontSize": 12,
|
||||
"marginBottom": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
Mattermost
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={3}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#2389d7",
|
||||
"fontSize": 14,
|
||||
"marginBottom": 10,
|
||||
},
|
||||
Object {
|
||||
"marginRight": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Title
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
@@ -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 {getDimensions} from 'app/selectors/device';
|
||||
|
||||
import PostAttachmentOpenGraph from './post_attachment_opengraph';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
...getDimensions(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(PostAttachmentOpenGraph);
|
||||
@@ -1,362 +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 {Alert, Text, View} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import {intlShape} from 'react-intl';
|
||||
import parseUrl from 'url-parse';
|
||||
|
||||
import {TABLET_WIDTH} from '@components/sidebars/drawer_layout';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {DeviceTypes} from '@constants';
|
||||
import {generateId} from '@utils/file';
|
||||
import {openGalleryAtIndex, calculateDimensions} from '@utils/images';
|
||||
import {getNearestPoint} from '@utils/opengraph';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {tryOpenURL, isValidUrl} from '@utils/url';
|
||||
|
||||
const MAX_IMAGE_HEIGHT = 150;
|
||||
const VIEWPORT_IMAGE_OFFSET = 93;
|
||||
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
|
||||
|
||||
export default class PostAttachmentOpenGraph extends PureComponent {
|
||||
static propTypes = {
|
||||
deviceHeight: PropTypes.number.isRequired,
|
||||
deviceWidth: PropTypes.number.isRequired,
|
||||
imagesMetadata: PropTypes.object,
|
||||
isReplyPost: PropTypes.bool,
|
||||
link: PropTypes.string.isRequired,
|
||||
openGraphData: PropTypes.object,
|
||||
postId: PropTypes.string,
|
||||
theme: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.fileId = generateId();
|
||||
this.state = this.getBestImageUrlAndDimensions(props.openGraphData);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
|
||||
if (this.state.openGraphImageUrl) {
|
||||
this.getImageSize(this.state.openGraphImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
getBestImageUrlAndDimensions = (data) => {
|
||||
if (!data || !data.images) {
|
||||
return {
|
||||
hasImage: false,
|
||||
};
|
||||
}
|
||||
|
||||
const {imagesMetadata} = this.props;
|
||||
const bestDimensions = {
|
||||
width: this.getViewPostWidth(),
|
||||
height: MAX_IMAGE_HEIGHT,
|
||||
};
|
||||
|
||||
const bestImage = getNearestPoint(bestDimensions, data.images, 'width', 'height');
|
||||
const imageUrl = bestImage.secure_url || bestImage.url;
|
||||
|
||||
let ogImage;
|
||||
if (imagesMetadata && imagesMetadata[imageUrl]) {
|
||||
ogImage = imagesMetadata[imageUrl];
|
||||
}
|
||||
|
||||
if (!ogImage) {
|
||||
ogImage = data.images.find((i) => i.url === imageUrl || i.secure_url === imageUrl);
|
||||
}
|
||||
|
||||
// Fallback when the ogImage does not have dimensions but there is a metaImage defined
|
||||
const metaImages = imagesMetadata ? Object.values(imagesMetadata) : null;
|
||||
if ((!ogImage?.width || !ogImage?.height) && metaImages?.length) {
|
||||
ogImage = metaImages[0];
|
||||
}
|
||||
|
||||
let dimensions = bestDimensions;
|
||||
if (ogImage?.width && ogImage?.height) {
|
||||
dimensions = calculateDimensions(ogImage.height, ogImage.width, this.getViewPostWidth());
|
||||
}
|
||||
|
||||
return {
|
||||
hasImage: Boolean(isValidUrl(imageUrl) && (ogImage.width && ogImage.height)),
|
||||
...dimensions,
|
||||
openGraphImageUrl: imageUrl,
|
||||
};
|
||||
};
|
||||
|
||||
getFilename = (uri) => {
|
||||
const link = decodeURIComponent(uri);
|
||||
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
|
||||
const extension = filename.split('.').pop();
|
||||
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
}
|
||||
|
||||
return `og-${filename.replace(/:/g, '-')}`;
|
||||
};
|
||||
|
||||
getImageSize = (imageUrl) => {
|
||||
const {imagesMetadata, openGraphData} = this.props;
|
||||
const {openGraphImageUrl} = this.state;
|
||||
|
||||
let ogImage;
|
||||
if (imagesMetadata && imagesMetadata[openGraphImageUrl]) {
|
||||
ogImage = imagesMetadata[openGraphImageUrl];
|
||||
}
|
||||
|
||||
if (!ogImage) {
|
||||
ogImage = openGraphData?.images?.find((i) => i.url === openGraphImageUrl || i.secure_url === openGraphImageUrl);
|
||||
}
|
||||
|
||||
// Fallback when the ogImage does not have dimensions but there is a metaImage defined
|
||||
const metaImages = imagesMetadata ? Object.values(imagesMetadata) : null;
|
||||
if ((!ogImage?.width || !ogImage?.height) && metaImages?.length) {
|
||||
ogImage = metaImages[0];
|
||||
}
|
||||
|
||||
if (ogImage?.width && ogImage?.height) {
|
||||
this.setImageSize(imageUrl, ogImage.width, ogImage.height);
|
||||
}
|
||||
};
|
||||
|
||||
setImageSize = (imageUrl, originalWidth, originalHeight) => {
|
||||
if (this.mounted) {
|
||||
const dimensions = calculateDimensions(originalHeight, originalWidth, this.getViewPostWidth());
|
||||
|
||||
this.setState({
|
||||
imageUrl,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
...dimensions,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getViewPostWidth = () => {
|
||||
const {deviceHeight, deviceWidth, isReplyPost} = this.props;
|
||||
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
|
||||
const viewPortWidth = deviceSize - VIEWPORT_IMAGE_OFFSET - (isReplyPost ? VIEWPORT_IMAGE_REPLY_OFFSET : 0);
|
||||
const tabletOffset = DeviceTypes.IS_TABLET ? TABLET_WIDTH : 0;
|
||||
|
||||
return viewPortWidth - tabletOffset;
|
||||
};
|
||||
|
||||
goToLink = () => {
|
||||
const {intl} = this.context;
|
||||
const onError = () => {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.text',
|
||||
defaultMessage: 'Unable to open the link.',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
tryOpenURL(this.props.link, onError);
|
||||
};
|
||||
|
||||
handlePreviewImage = () => {
|
||||
const {
|
||||
imageUrl: uri,
|
||||
openGraphImageUrl: link,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
} = this.state;
|
||||
const filename = this.getFilename(link);
|
||||
const extension = filename.split('.').pop();
|
||||
|
||||
const files = [{
|
||||
id: this.fileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: this.props.postId,
|
||||
uri,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
}];
|
||||
|
||||
openGalleryAtIndex(0, files);
|
||||
};
|
||||
|
||||
renderDescription = () => {
|
||||
const {openGraphData} = this.props;
|
||||
if (!openGraphData.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
|
||||
return (
|
||||
<View style={style.flex}>
|
||||
<Text
|
||||
style={style.siteDescription}
|
||||
numberOfLines={5}
|
||||
ellipsizeMode='tail'
|
||||
>
|
||||
{openGraphData.description}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
renderImage = () => {
|
||||
if (!this.state.hasImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {height, openGraphImageUrl, width} = this.state;
|
||||
|
||||
let source;
|
||||
if (openGraphImageUrl) {
|
||||
source = {
|
||||
uri: openGraphImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
|
||||
return (
|
||||
<View style={[style.imageContainer, {width, height}]}>
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePreviewImage}
|
||||
type={'none'}
|
||||
>
|
||||
<FastImage
|
||||
style={[style.image, {width, height}]}
|
||||
source={source}
|
||||
resizeMode='contain'
|
||||
nativeID={`image-${this.fileId}`}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
isReplyPost,
|
||||
link,
|
||||
openGraphData,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
if (!openGraphData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let siteName;
|
||||
if (openGraphData.site_name) {
|
||||
siteName = (
|
||||
<View style={style.flex}>
|
||||
<Text
|
||||
style={style.siteTitle}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
>
|
||||
{openGraphData.site_name}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const title = openGraphData.title || openGraphData.url || link;
|
||||
let siteTitle;
|
||||
if (title) {
|
||||
siteTitle = (
|
||||
<View style={style.wrapper}>
|
||||
<TouchableWithFeedback
|
||||
style={style.flex}
|
||||
onPress={this.goToLink}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Text
|
||||
style={[style.siteSubtitle, {marginRight: isReplyPost ? 10 : 0}]}
|
||||
numberOfLines={3}
|
||||
ellipsizeMode='tail'
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
{siteName}
|
||||
{siteTitle}
|
||||
{this.renderDescription()}
|
||||
{this.renderImage()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
marginTop: 10,
|
||||
padding: 10,
|
||||
},
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
siteTitle: {
|
||||
fontSize: 12,
|
||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
marginBottom: 10,
|
||||
},
|
||||
siteSubtitle: {
|
||||
fontSize: 14,
|
||||
color: theme.linkColor,
|
||||
marginBottom: 10,
|
||||
},
|
||||
siteDescription: {
|
||||
fontSize: 13,
|
||||
color: changeOpacity(theme.centerChannelColor, 0.7),
|
||||
marginBottom: 10,
|
||||
},
|
||||
imageContainer: {
|
||||
alignItems: 'center',
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
marginTop: 5,
|
||||
},
|
||||
image: {
|
||||
borderRadius: 3,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
import PostAttachmentOpenGraph from './post_attachment_opengraph';
|
||||
|
||||
describe('PostAttachmentOpenGraph', () => {
|
||||
const openGraphData = {
|
||||
site_name: 'Mattermost',
|
||||
title: 'Title',
|
||||
url: 'https://mattermost.com/',
|
||||
images: [{
|
||||
secure_url: 'https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png',
|
||||
}],
|
||||
};
|
||||
const baseProps = {
|
||||
actions: {
|
||||
getOpenGraphMetadata: jest.fn(),
|
||||
},
|
||||
deviceHeight: 600,
|
||||
deviceWidth: 400,
|
||||
imagesMetadata: {
|
||||
'https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png': {
|
||||
width: 1165,
|
||||
height: 265,
|
||||
},
|
||||
},
|
||||
isReplyPost: false,
|
||||
link: 'https://mattermost.com/',
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
test('should match snapshot, without image and description', () => {
|
||||
let wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
|
||||
wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphData}
|
||||
/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('should match snapshot, without site_name', () => {
|
||||
const newOpenGraphData = {
|
||||
title: 'Title',
|
||||
url: 'https://mattermost.com/',
|
||||
};
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={newOpenGraphData}
|
||||
/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot, without title and url', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={{}}
|
||||
/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match state and snapshot, on renderImage', () => {
|
||||
let wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
expect(wrapper.instance().renderImage()).toMatchSnapshot();
|
||||
expect(wrapper.state('hasImage')).toEqual(false);
|
||||
expect(wrapper.find(FastImage).exists()).toEqual(false);
|
||||
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(false);
|
||||
|
||||
const images = [{height: 440, width: 1200, url: 'https://mattermost.com/logo.png'}];
|
||||
const openGraphDataWithImage = {...openGraphData, images};
|
||||
wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphDataWithImage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.instance().renderImage()).toMatchSnapshot();
|
||||
expect(wrapper.state('hasImage')).toEqual(true);
|
||||
expect(wrapper.find(FastImage).exists()).toEqual(true);
|
||||
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('should match state and snapshot, on renderImage', () => {
|
||||
const images = [{height: 440, width: 1200, url: '%REACT_APP_WEBSITE_BANNER%'}];
|
||||
const openGraphDataWithImage = {...openGraphData, images};
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphDataWithImage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.state('hasImage')).toEqual(false);
|
||||
expect(wrapper.find(FastImage).exists()).toEqual(false);
|
||||
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('should match state and snapshot, on renderDescription', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphData}
|
||||
/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
expect(wrapper.instance().renderDescription()).toMatchSnapshot();
|
||||
|
||||
const openGraphDataWithDescription = {...openGraphData, description: 'Description'};
|
||||
wrapper.setProps({openGraphData: openGraphDataWithDescription});
|
||||
expect(wrapper.instance().renderDescription()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match result on getFilename', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
const testCases = [
|
||||
{link: 'https://mattermost.com/image.png', result: 'og-image.png'},
|
||||
{link: 'https://mattermost.com/image.jpg', result: 'og-image.jpg'},
|
||||
{link: 'https://mattermost.com/image', result: 'og-image.png'},
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => { // eslint-disable-line max-nested-callbacks
|
||||
expect(wrapper.instance().getFilename(testCase.link)).toEqual(testCase.result);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} updated the channel header from: {oldHeader} to: {newHeader}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for Channel Purpose update 1`] = `
|
||||
<Text
|
||||
style={Object {}}
|
||||
>
|
||||
{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}
|
||||
</Text>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for OLD archived channel without a username 1`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} archived the channel
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for archived channel 1`] = `
|
||||
<Connect(Markdown)
|
||||
baseTextStyle={Object {}}
|
||||
disableAtChannelMentionHighlight={true}
|
||||
disableGallery={true}
|
||||
onPostPress={[MockFunction]}
|
||||
textStyles={Object {}}
|
||||
value="{username} archived the channel"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for archived channel 2`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} archived the channel
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} unarchived the channel
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user