Compare commits

...

15 Commits
v1 ... v1.44.0

Author SHA1 Message Date
Mattermost Build
2ba6b1d641 Bump app build number to 359 (#5458) (#5459)
(cherry picked from commit 362006db29)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-14 16:30:47 -04:00
Mattermost Build
286f05c3be fix: connect websocket when the component mounts (#5456) (#5457)
(cherry picked from commit 0e81e0e2a8)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-14 16:01:54 -04:00
Mattermost Build
e1329d41a3 Bump app build number to 358 (#5450) (#5451)
(cherry picked from commit d871029b9d)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-11 19:04:41 -04:00
Mattermost Build
bc39b38bf4 fix: reset iOS scrollView when switching channels (#5447) (#5448)
* fix: reset iOS scrollView when switching channels

* cancel animation frame after resetting the scrollview

* add useResetNativeScrollView hook

(cherry picked from commit b2d233b5ed)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-11 18:44:49 -04:00
Mattermost Build
9173752390 1.44 fixes (#5444) (#5446)
* fix: get status as soon as appstate is in the foreground

* re-render post list when theme changes

* remove invalid userIds from get status request

(cherry picked from commit 6335932883)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-11 14:46:26 -04:00
Mattermost Build
033241691c MM-36269 fix post reaction handle press (#5438) (#5442)
(cherry picked from commit c7ef19d36e)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-09 15:57:10 -04:00
Mattermost Build
3a4c9e75bf Fixed the warning about isCustomStatusEnabled prop in ChannelInfo (#5436) (#5439) 2021-06-08 13:58:13 -04:00
Mattermost Build
840fda2051 MM-36256 avoid path traversal for Android image picker (#5432) (#5437)
(cherry picked from commit 0a4dafa127)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-08 09:24:33 -04:00
Mattermost Build
afa18fb5d9 set npm version as part of the npm-dependencies CI task (#5427) (#5428)
* set npm version as part of the npm-dependencies CI task

* Set branch name to only build ios-simulator

(cherry picked from commit 37c74ecef0)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-05 11:47:14 -04:00
Mattermost Build
84de817451 Fix Fastlane Webhook (#5424) (#5425)
(cherry picked from commit e2bb6497df)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-04 11:07:08 -04:00
Mattermost Build
93777aefe6 Force CI to use npm 6.14.11 (#5414) (#5423)
(cherry picked from commit 339a4b0554)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-04 10:46:39 -04:00
Mattermost Build
825d8fcad3 Version number (#5421) (#5422)
* Bump app build number to  357

* Bump app version number to  1.44.0

(cherry picked from commit e87bc86f44)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-06-04 10:05:18 -04:00
Mattermost Build
abc2f30ef3 MM-34586 Custom status feature (#5220) (#5420)
(cherry picked from commit e442275c6f)

Co-authored-by: Chetanya Kandhari <chetanya.kandhari@brightscout.com>
2021-06-04 09:02:35 -04:00
Mattermost Build
0d83ac4c93 Post List & post components refactored (#5409) (#5417) 2021-06-03 14:59:42 -04:00
Mattermost Build
cf88a2ae8f Update NOTICE.txt (#5415) (#5416)
(cherry picked from commit 2e34ee4a80)

Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>
2021-06-03 13:31:46 -04:00
425 changed files with 14485 additions and 17918 deletions

View File

@@ -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

View File

@@ -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.
---

View File

@@ -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'

View File

@@ -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

View File

@@ -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();

View File

@@ -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},
},

View File

@@ -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);
});

View File

@@ -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};
}

View 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,
};

View File

@@ -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 {};
};
}

View File

@@ -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));

View File

@@ -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',
];

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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';

View File

@@ -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();

View File

@@ -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([

View File

@@ -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);

View File

@@ -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={

View File

@@ -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,
},
};
});

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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();
});

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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>
`;

View 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();
});
});

View 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),
},
};
});

View 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();
});
});

View 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;

View 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();
});
});

View 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,
},
};
});

View File

@@ -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={

View File

@@ -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';

View File

@@ -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,
},
};
});

View File

@@ -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);

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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}
/>
);
}
}

View File

@@ -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';

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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',
},
};
});

View File

@@ -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();
});
});

View File

@@ -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,
},
});

View File

@@ -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',
},
});

View File

@@ -1,195 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
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',
},
});

View File

@@ -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();
});
});
});

View File

@@ -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,
},
});

View File

@@ -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();
});
});

View File

@@ -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>;
}
}

View 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;

View File

@@ -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);
}
}

View 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);

View File

@@ -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>
);
}
}

View File

@@ -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',
);

View 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;

View File

@@ -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});
});

View File

@@ -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();

View File

@@ -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';

View File

@@ -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() {

View File

@@ -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();
});
});

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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:',
};

View File

@@ -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';

View File

@@ -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),
};
}

View File

@@ -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 = () => {

View File

@@ -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';

View File

@@ -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,
},
};
});

View File

@@ -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);

View File

@@ -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>
`;

View File

@@ -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);
});
});

View File

@@ -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,
},
};
});

View File

@@ -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,
},
});

View File

@@ -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,
},
};
});

View File

@@ -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>
);
}

View File

@@ -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)>
`;

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {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);

View 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);

View 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;

View File

@@ -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,
},
});

View File

@@ -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);
});
});
});

View File

@@ -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);

View File

@@ -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),
},
};
});

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {addChannelMember} from '@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);

View File

@@ -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}
</>
);
}
}

View File

@@ -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>
`;

View File

@@ -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,
},
});

View File

@@ -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();
});
});

View File

@@ -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>
`;

View File

@@ -1,16 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getDimensions} from 'app/selectors/device';
import PostAttachmentOpenGraph from './post_attachment_opengraph';
function mapStateToProps(state) {
return {
...getDimensions(state),
};
}
export default connect(mapStateToProps)(PostAttachmentOpenGraph);

View File

@@ -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,
},
};
});

View File

@@ -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);
});
});
});

View File

@@ -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