parent
cf88a2ae8f
commit
0d83ac4c93
362 changed files with 9954 additions and 17522 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
|||
import EventEmmiter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import {DeviceTypes, NavigationTypes} from '@constants';
|
||||
import {CHANNEL} from '@constants/screen';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import Store from '@store/store';
|
||||
|
||||
|
|
@ -33,8 +34,8 @@ export function resetToChannel(passProps = {}) {
|
|||
const stack = {
|
||||
children: [{
|
||||
component: {
|
||||
id: NavigationTypes.CHANNEL_SCREEN,
|
||||
name: NavigationTypes.CHANNEL_SCREEN,
|
||||
id: CHANNEL,
|
||||
name: CHANNEL,
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
|
|
@ -363,17 +364,20 @@ export async function dismissModal(options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function dismissAllModals(options = {}) {
|
||||
export async function dismissAllModals(options) {
|
||||
if (!EphemeralStore.hasModalsOpened()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Platform.OS === 'ios') {
|
||||
const modals = [...EphemeralStore.navigationModalStack];
|
||||
for await (const modal of modals) {
|
||||
await Navigation.dismissModal(modal, options);
|
||||
EphemeralStore.removeNavigationModal(modal);
|
||||
}
|
||||
} else {
|
||||
await Navigation.dismissAllModals(options);
|
||||
EphemeralStore.clearNavigationModals();
|
||||
} catch (error) {
|
||||
// RNN returns a promise rejection if there are no modals to
|
||||
// dismiss. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -441,7 +445,7 @@ export function closeMainSideMenu() {
|
|||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
Navigation.mergeOptions(CHANNEL, {
|
||||
sideMenu: {
|
||||
left: {visible: false},
|
||||
},
|
||||
|
|
@ -453,7 +457,7 @@ export function enableMainSideMenu(enabled, visible = true) {
|
|||
return;
|
||||
}
|
||||
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
Navigation.mergeOptions(CHANNEL, {
|
||||
sideMenu: {
|
||||
left: {enabled, visible},
|
||||
},
|
||||
|
|
@ -466,7 +470,7 @@ export function openSettingsSideMenu() {
|
|||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
Navigation.mergeOptions(CHANNEL, {
|
||||
sideMenu: {
|
||||
right: {visible: true},
|
||||
},
|
||||
|
|
@ -479,7 +483,7 @@ export function closeSettingsSideMenu() {
|
|||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
Navigation.mergeOptions(CHANNEL, {
|
||||
sideMenu: {
|
||||
right: {visible: false},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,17 +17,23 @@ import Store from '@store/store';
|
|||
import {NavigationTypes} from '@constants';
|
||||
|
||||
jest.unmock('@actions/navigation');
|
||||
jest.mock('@store/ephemeral_store', () => ({
|
||||
getNavigationTopComponentId: jest.fn(),
|
||||
clearNavigationComponents: jest.fn(),
|
||||
addNavigationModal: jest.fn(),
|
||||
hasModalsOpened: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const store = mockStore(intitialState);
|
||||
Store.redux = store;
|
||||
|
||||
// Mock EphemeralStore add/remove modal
|
||||
const add = EphemeralStore.addNavigationModal;
|
||||
const remove = EphemeralStore.removeNavigationModal;
|
||||
EphemeralStore.removeNavigationModal = (componentId) => {
|
||||
remove(componentId);
|
||||
EphemeralStore.removeNavigationComponentId(componentId);
|
||||
};
|
||||
|
||||
EphemeralStore.addNavigationModal = (componentId) => {
|
||||
add(componentId);
|
||||
EphemeralStore.addNavigationComponentId(componentId);
|
||||
};
|
||||
|
||||
describe('@actions/navigation', () => {
|
||||
const topComponentId = 'top-component-id';
|
||||
const name = 'name';
|
||||
|
|
@ -39,7 +45,16 @@ describe('@actions/navigation', () => {
|
|||
const options = {
|
||||
testOption: 'test',
|
||||
};
|
||||
EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
|
||||
|
||||
beforeEach(() => {
|
||||
EphemeralStore.clearNavigationComponents();
|
||||
EphemeralStore.clearNavigationModals();
|
||||
|
||||
// mock that we have a root screen
|
||||
EphemeralStore.addNavigationComponentId(topComponentId);
|
||||
});
|
||||
|
||||
// EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
|
||||
|
||||
test('resetToChannel should call Navigation.setRoot', () => {
|
||||
const setRoot = jest.spyOn(Navigation, 'setRoot');
|
||||
|
|
@ -425,15 +440,20 @@ describe('@actions/navigation', () => {
|
|||
test('dismissModal should call Navigation.dismissModal', async () => {
|
||||
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
|
||||
|
||||
NavigationActions.showModal('First', 'First Modal', passProps, options);
|
||||
|
||||
await NavigationActions.dismissModal(options);
|
||||
expect(dismissModal).toHaveBeenCalledWith(topComponentId, options);
|
||||
expect(dismissModal).toHaveBeenCalledWith('First', options);
|
||||
});
|
||||
|
||||
test('dismissAllModals should call Navigation.dismissAllModals', async () => {
|
||||
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
|
||||
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
|
||||
|
||||
NavigationActions.showModal('First', 'First Modal', passProps, options);
|
||||
NavigationActions.showModal('Second', 'Second Modal', passProps, options);
|
||||
|
||||
await NavigationActions.dismissAllModals(options);
|
||||
expect(dismissAllModals).toHaveBeenCalledWith(options);
|
||||
expect(dismissModal).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('mergeNavigationOptions should call Navigation.mergeOptions', () => {
|
||||
|
|
@ -493,12 +513,15 @@ describe('@actions/navigation', () => {
|
|||
});
|
||||
|
||||
test('dismissAllModalsAndPopToRoot should call Navigation.dismissAllModals, Navigation.popToRoot, and emit event', async () => {
|
||||
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
|
||||
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
|
||||
const popToRoot = jest.spyOn(Navigation, 'popToRoot');
|
||||
EventEmitter.emit = jest.fn();
|
||||
|
||||
NavigationActions.showModal('First', 'First Modal', passProps, options);
|
||||
NavigationActions.showModal('Second', 'Second Modal', passProps, options);
|
||||
|
||||
await NavigationActions.dismissAllModalsAndPopToRoot();
|
||||
expect(dismissAllModals).toHaveBeenCalled();
|
||||
expect(dismissModal).toHaveBeenCalledTimes(2);
|
||||
expect(popToRoot).toHaveBeenCalledWith(topComponentId);
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler, i
|
|||
// Fallback to API response error, if any.
|
||||
if (teamError) {
|
||||
if (errorHandler) {
|
||||
errorHandler();
|
||||
errorHandler(intl);
|
||||
}
|
||||
return {error: teamError};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,15 @@
|
|||
import {intlShape} from 'react-intl';
|
||||
import {Keyboard} from 'react-native';
|
||||
|
||||
import {showModalOverCurrentContext} from '@actions/navigation';
|
||||
import {dismissAllModals, showModalOverCurrentContext} from '@actions/navigation';
|
||||
import {loadChannelsByTeamName} from '@actions/views/channel';
|
||||
import {selectFocusedPostId} from '@mm-redux/actions/posts';
|
||||
import type {DispatchFunc} from '@mm-redux/types/actions';
|
||||
import {permalinkBadTeam} from '@utils/general';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
export let showingPermalink = false;
|
||||
import type {DispatchFunc} from '@mm-redux/types/actions';
|
||||
|
||||
let showingPermalink = false;
|
||||
|
||||
export function showPermalink(intl: typeof intlShape, teamName: string, postId: string, openAsPermalink = true) {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
|
|
@ -20,27 +21,26 @@ export function showPermalink(intl: typeof intlShape, teamName: string, postId:
|
|||
if (!loadTeam.error) {
|
||||
Keyboard.dismiss();
|
||||
dispatch(selectFocusedPostId(postId));
|
||||
|
||||
if (!showingPermalink) {
|
||||
const screen = 'Permalink';
|
||||
const passProps = {
|
||||
isPermalink: openAsPermalink,
|
||||
onClose: () => {
|
||||
dispatch(closePermalink());
|
||||
},
|
||||
teamName,
|
||||
};
|
||||
|
||||
const options = {
|
||||
layout: {
|
||||
componentBackgroundColor: changeOpacity('#000', 0.2),
|
||||
},
|
||||
};
|
||||
|
||||
showingPermalink = true;
|
||||
showModalOverCurrentContext(screen, passProps, options);
|
||||
if (showingPermalink) {
|
||||
await dismissAllModals();
|
||||
}
|
||||
|
||||
const screen = 'Permalink';
|
||||
const passProps = {
|
||||
isPermalink: openAsPermalink,
|
||||
teamName,
|
||||
};
|
||||
|
||||
const options = {
|
||||
layout: {
|
||||
componentBackgroundColor: changeOpacity('#000', 0.2),
|
||||
},
|
||||
};
|
||||
|
||||
showingPermalink = true;
|
||||
showModalOverCurrentContext(screen, passProps, options);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {loadChannelsForTeam} from '@actions/views/channel';
|
||||
import {loadChannelsForTeam, setChannelRetryFailed} from '@actions/views/channel';
|
||||
import {getPostsSince} from '@actions/views/post';
|
||||
import {loadMe} from '@actions/views/user';
|
||||
import {WebsocketEvents} from '@constants';
|
||||
|
|
@ -135,7 +135,10 @@ export function doReconnect(now: number) {
|
|||
const {lastDisconnectAt} = state.websocket;
|
||||
const actions: Array<GenericAction> = [];
|
||||
|
||||
dispatch(wsConnected(now));
|
||||
dispatch(batchActions([
|
||||
wsConnected(now),
|
||||
setChannelRetryFailed(false),
|
||||
], 'BATCH_WS_SUCCESS'));
|
||||
|
||||
try {
|
||||
const {data: me}: any = await dispatch(loadMe(null, null, true));
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {Server, WebSocket as MockWebSocket} from 'mock-socket';
|
|||
import thunk from 'redux-thunk';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
|
||||
import {GeneralTypes, UserTypes} from '@mm-redux/action_types';
|
||||
import {UserTypes} from '@mm-redux/action_types';
|
||||
import {notVisibleUsersActions} from '@mm-redux/actions/helpers';
|
||||
import {Client4} from '@client/rest';
|
||||
import {General, Posts, RequestStatus} from '@mm-redux/constants';
|
||||
|
|
@ -176,7 +176,7 @@ describe('Actions.Websocket doReconnect', () => {
|
|||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
];
|
||||
const expectedMissingActions = [
|
||||
'BATCH_WS_RECONNECT',
|
||||
|
|
@ -215,7 +215,7 @@ describe('Actions.Websocket doReconnect', () => {
|
|||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
'BATCH_WS_RECONNECT',
|
||||
];
|
||||
const expectedMissingActions = [
|
||||
|
|
@ -260,7 +260,7 @@ describe('Actions.Websocket doReconnect', () => {
|
|||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
];
|
||||
const expectedMissingActions = [
|
||||
'BATCH_WS_RECONNECT',
|
||||
|
|
@ -304,7 +304,7 @@ describe('Actions.Websocket doReconnect', () => {
|
|||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
'BATCH_WS_RECONNECT',
|
||||
];
|
||||
const expectedMissingActions = [
|
||||
|
|
@ -337,7 +337,7 @@ describe('Actions.Websocket doReconnect', () => {
|
|||
const testStore = await mockStore(state);
|
||||
const timestamp = 1000;
|
||||
const expectedActions = [
|
||||
GeneralTypes.WEBSOCKET_SUCCESS,
|
||||
'BATCH_WS_SUCCESS',
|
||||
'BATCH_WS_LEAVE_TEAM',
|
||||
'BATCH_WS_RECONNECT',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ import mattermostManaged from 'app/mattermost_managed';
|
|||
export default class AtMention extends React.PureComponent {
|
||||
static propTypes = {
|
||||
isSearchResult: PropTypes.bool,
|
||||
mentionKeys: PropTypes.array.isRequired,
|
||||
mentionKeys: PropTypes.array,
|
||||
mentionName: PropTypes.string.isRequired,
|
||||
mentionStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
onPostPress: PropTypes.func,
|
||||
textStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
teammateNameDisplay: PropTypes.string,
|
||||
theme: PropTypes.object.isRequired,
|
||||
usersByUsername: PropTypes.object.isRequired,
|
||||
usersByUsername: PropTypes.object,
|
||||
groupsByName: PropTypes.object,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,10 @@
|
|||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getUsersByUsername} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
|
||||
|
||||
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
|
||||
import {getAllGroupsForReferenceByName} from '@mm-redux/selectors/entities/groups';
|
||||
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
|
||||
import {getUsersByUsername} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import AtMention from './at_mention';
|
||||
|
||||
|
|
|
|||
|
|
@ -2,25 +2,21 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Permissions from 'react-native-permissions';
|
||||
import {Alert, StatusBar} from 'react-native';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
import {VALID_MIME_TYPES} from 'app/screens/edit_profile/edit_profile';
|
||||
import {VALID_MIME_TYPES} from '@screens/edit_profile/edit_profile';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import AttachmentButton from './index';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchCamera: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
|
||||
launchImageLibrary: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
|
||||
}));
|
||||
|
||||
describe('AttachmentButton', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
theme: Preferences.THEMES.default,
|
||||
maxFileSize: 10,
|
||||
|
|
@ -28,7 +24,7 @@ describe('AttachmentButton', () => {
|
|||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<AttachmentButton {...baseProps}/>);
|
||||
const wrapper = shallowWithIntl(<AttachmentButton {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -40,7 +36,7 @@ describe('AttachmentButton', () => {
|
|||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(<AttachmentButton {...props}/>);
|
||||
const wrapper = shallowWithIntl(<AttachmentButton {...props}/>);
|
||||
|
||||
const file = {
|
||||
type: 'image/gif',
|
||||
|
|
@ -59,7 +55,7 @@ describe('AttachmentButton', () => {
|
|||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(<AttachmentButton {...props}/>);
|
||||
const wrapper = shallowWithIntl(<AttachmentButton {...props}/>);
|
||||
|
||||
const file = {
|
||||
fileSize: 10,
|
||||
|
|
@ -77,9 +73,8 @@ describe('AttachmentButton', () => {
|
|||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
|
||||
|
|
@ -93,9 +88,8 @@ describe('AttachmentButton', () => {
|
|||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
|
||||
|
|
@ -106,9 +100,8 @@ describe('AttachmentButton', () => {
|
|||
});
|
||||
|
||||
test('should re-enable StatusBar after ImagePicker launchCamera finishes', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
|
|
@ -120,9 +113,8 @@ describe('AttachmentButton', () => {
|
|||
});
|
||||
|
||||
test('should re-enable StatusBar after ImagePicker launchImageLibrary finishes', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import {shallow} from 'enzyme';
|
|||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import {Command, AutocompleteSuggestion} from '@mm-redux/types/integrations';
|
||||
|
||||
import Store from '@store/store';
|
||||
import {intl} from 'test/intl-test-helper';
|
||||
|
||||
import {
|
||||
thunk,
|
||||
|
|
@ -86,7 +86,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
|||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
|
||||
const dataSource: AutocompleteSuggestion[] = [
|
||||
{
|
||||
|
|
@ -117,7 +120,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
|||
commands: [command],
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/the'});
|
||||
|
||||
expect(wrapper.state('dataSource')).toEqual([
|
||||
|
|
@ -137,7 +143,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
|||
commands: [],
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/ji'});
|
||||
|
||||
expect(wrapper.state('dataSource')).toEqual([
|
||||
|
|
@ -156,7 +165,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
|||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
|
||||
wrapper.setProps({value: '/'});
|
||||
expect(wrapper.state('dataSource')).toEqual([
|
||||
|
|
@ -207,7 +219,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
|||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/jira i', suggestions: []});
|
||||
|
||||
const expected: AutocompleteSuggestion[] = [
|
||||
|
|
@ -232,7 +247,10 @@ describe('components/autocomplete/slash_suggestion', () => {
|
|||
appsEnabled: false,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/', suggestions: []});
|
||||
|
||||
expect(wrapper.state('dataSource')).toEqual([
|
||||
|
|
|
|||
|
|
@ -2,15 +2,13 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
import {Text} from 'react-native';
|
||||
|
||||
import {alertErrorWithFallback} from '@utils/general';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import ChannelLink from './channel_link';
|
||||
|
||||
jest.mock('react-intl');
|
||||
|
||||
jest.mock('@utils/general', () => {
|
||||
const general = jest.requireActual('../../utils/general');
|
||||
return {
|
||||
|
|
@ -20,7 +18,6 @@ jest.mock('@utils/general', () => {
|
|||
});
|
||||
|
||||
describe('ChannelLink', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const channelsByName = {
|
||||
firstChannel: {id: 'channel_id_1', name: 'firstChannel', display_name: 'First Channel', team_id: 'current_team_id'},
|
||||
secondChannel: {id: 'channel_id_2', name: 'secondChannel', display_name: 'Second Channel', team_id: 'current_team_id'},
|
||||
|
|
@ -40,9 +37,8 @@ describe('ChannelLink', () => {
|
|||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ChannelLink {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
|
|
@ -74,9 +70,8 @@ describe('ChannelLink', () => {
|
|||
});
|
||||
|
||||
test('should call props.actions and onChannelLinkPress on handlePress', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ChannelLink {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const channel = channelsByName.firstChannel;
|
||||
|
|
@ -106,16 +101,17 @@ describe('ChannelLink', () => {
|
|||
channelName: newChannelName,
|
||||
actions: {...baseProps.actions, joinChannel},
|
||||
};
|
||||
const intl = {formatMessage};
|
||||
|
||||
const joinFailedMessage = {
|
||||
id: 'mobile.join_channel.error',
|
||||
defaultMessage: 'We couldn\'t join the channel {displayName}. Please check your connection and try again.',
|
||||
};
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ChannelLink {...newProps}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
|
||||
const {intl} = wrapper.context();
|
||||
|
||||
await wrapper.instance().handlePress();
|
||||
expect(newProps.actions.joinChannel).toHaveBeenCalledTimes(1);
|
||||
expect(newProps.actions.joinChannel).toBeCalledWith('current_user_id', 'current_team_id', null, newChannelName);
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
|
|||
size="small"
|
||||
/>
|
||||
</View>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Still trying to load your content..."
|
||||
id="mobile.channel_loader.still_loading"
|
||||
style={
|
||||
|
|
|
|||
|
|
@ -1,393 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
||||
import Markdown from 'app/components/markdown';
|
||||
|
||||
import LastUsers from './last_users';
|
||||
|
||||
const {
|
||||
JOIN_CHANNEL, ADD_TO_CHANNEL, REMOVE_FROM_CHANNEL, LEAVE_CHANNEL,
|
||||
JOIN_TEAM, ADD_TO_TEAM, REMOVE_FROM_TEAM, LEAVE_TEAM,
|
||||
} = Posts.POST_TYPES;
|
||||
|
||||
const postTypeMessage = {
|
||||
[JOIN_CHANNEL]: {
|
||||
one: {
|
||||
id: t('combined_system_message.joined_channel.one'),
|
||||
defaultMessage: '{firstUser} **joined the channel**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.joined_channel.one_you'),
|
||||
defaultMessage: 'You **joined the channel**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.joined_channel.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **joined the channel**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.joined_channel.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} **joined the channel**.',
|
||||
},
|
||||
},
|
||||
[ADD_TO_CHANNEL]: {
|
||||
one: {
|
||||
id: t('combined_system_message.added_to_channel.one'),
|
||||
defaultMessage: '{firstUser} **added to the channel** by {actor}.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.added_to_channel.one_you'),
|
||||
defaultMessage: 'You were **added to the channel** by {actor}.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.added_to_channel.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **added to the channel** by {actor}.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.added_to_channel.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} were **added to the channel** by {actor}.',
|
||||
},
|
||||
},
|
||||
[REMOVE_FROM_CHANNEL]: {
|
||||
one: {
|
||||
id: t('combined_system_message.removed_from_channel.one'),
|
||||
defaultMessage: '{firstUser} was **removed from the channel**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.removed_from_channel.one_you'),
|
||||
defaultMessage: 'You were **removed from the channel**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.removed_from_channel.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} were **removed from the channel**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.removed_from_channel.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} were **removed from the channel**.',
|
||||
},
|
||||
},
|
||||
[LEAVE_CHANNEL]: {
|
||||
one: {
|
||||
id: t('combined_system_message.left_channel.one'),
|
||||
defaultMessage: '{firstUser} **left the channel**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.left_channel.one_you'),
|
||||
defaultMessage: 'You **left the channel**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.left_channel.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **left the channel**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.left_channel.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} **left the channel**.',
|
||||
},
|
||||
},
|
||||
[JOIN_TEAM]: {
|
||||
one: {
|
||||
id: t('combined_system_message.joined_team.one'),
|
||||
defaultMessage: '{firstUser} **joined the team**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.joined_team.one_you'),
|
||||
defaultMessage: 'You **joined the team**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.joined_team.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **joined the team**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.joined_team.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} **joined the team**.',
|
||||
},
|
||||
},
|
||||
[ADD_TO_TEAM]: {
|
||||
one: {
|
||||
id: t('combined_system_message.added_to_team.one'),
|
||||
defaultMessage: '{firstUser} **added to the team** by {actor}.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.added_to_team.one_you'),
|
||||
defaultMessage: 'You were **added to the team** by {actor}.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.added_to_team.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **added to the team** by {actor}.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.added_to_team.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} were **added to the team** by {actor}.',
|
||||
},
|
||||
},
|
||||
[REMOVE_FROM_TEAM]: {
|
||||
one: {
|
||||
id: t('combined_system_message.removed_from_team.one'),
|
||||
defaultMessage: '{firstUser} was **removed from the team**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.removed_from_team.one_you'),
|
||||
defaultMessage: 'You were **removed from the team**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.removed_from_team.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} were **removed from the team**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.removed_from_team.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} were **removed from the team**.',
|
||||
},
|
||||
},
|
||||
[LEAVE_TEAM]: {
|
||||
one: {
|
||||
id: t('combined_system_message.left_team.one'),
|
||||
defaultMessage: '{firstUser} **left the team**.',
|
||||
},
|
||||
one_you: {
|
||||
id: t('combined_system_message.left_team.one_you'),
|
||||
defaultMessage: 'You **left the team**.',
|
||||
},
|
||||
two: {
|
||||
id: t('combined_system_message.left_team.two'),
|
||||
defaultMessage: '{firstUser} and {secondUser} **left the team**.',
|
||||
},
|
||||
many_expanded: {
|
||||
id: t('combined_system_message.left_team.many_expanded'),
|
||||
defaultMessage: '{users} and {lastUser} **left the team**.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default class CombinedSystemMessage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
getMissingProfilesByIds: PropTypes.func.isRequired,
|
||||
getMissingProfilesByUsernames: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
allUserIds: PropTypes.array.isRequired,
|
||||
allUsernames: PropTypes.array.isRequired,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
currentUsername: PropTypes.string.isRequired,
|
||||
messageData: PropTypes.array.isRequired,
|
||||
showJoinLeave: PropTypes.bool.isRequired,
|
||||
textStyles: PropTypes.object,
|
||||
theme: PropTypes.object.isRequired,
|
||||
userProfiles: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
allUserIds: [],
|
||||
allUsernames: [],
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.loadUserProfiles(this.props.allUserIds, this.props.allUsernames);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {allUserIds, allUsernames} = this.props;
|
||||
if (allUserIds !== prevProps.allUserIds || allUsernames !== prevProps.allUsernames) {
|
||||
this.loadUserProfiles(allUserIds, allUsernames);
|
||||
}
|
||||
}
|
||||
|
||||
loadUserProfiles = (allUserIds, allUsernames) => {
|
||||
if (allUserIds.length > 0) {
|
||||
this.props.actions.getMissingProfilesByIds(allUserIds);
|
||||
}
|
||||
|
||||
if (allUsernames.length > 0) {
|
||||
this.props.actions.getMissingProfilesByUsernames(allUsernames);
|
||||
}
|
||||
}
|
||||
|
||||
getAllUsernames = () => {
|
||||
const {
|
||||
allUserIds,
|
||||
allUsernames,
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
userProfiles,
|
||||
} = this.props;
|
||||
const {formatMessage} = this.context.intl;
|
||||
const usernames = userProfiles.reduce((acc, user) => {
|
||||
acc[user.id] = user.username;
|
||||
acc[user.username] = user.username;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const currentUserDisplayName = formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'});
|
||||
if (allUserIds.includes(currentUserId)) {
|
||||
usernames[currentUserId] = currentUserDisplayName;
|
||||
} else if (allUsernames.includes(currentUsername)) {
|
||||
usernames[currentUsername] = currentUserDisplayName;
|
||||
}
|
||||
|
||||
return usernames;
|
||||
}
|
||||
|
||||
getUsernamesByIds = (userIds = []) => {
|
||||
const {currentUserId, currentUsername} = this.props;
|
||||
const allUsernames = this.getAllUsernames();
|
||||
|
||||
const {formatMessage} = this.context.intl;
|
||||
const someone = formatMessage({id: t('channel_loader.someone'), defaultMessage: 'Someone'});
|
||||
|
||||
const usernames = userIds.
|
||||
filter((userId) => {
|
||||
return userId !== currentUserId && userId !== currentUsername;
|
||||
}).
|
||||
map((userId) => {
|
||||
return allUsernames[userId] ? `@${allUsernames[userId]}` : someone;
|
||||
}).filter((username) => {
|
||||
return username && username !== '';
|
||||
});
|
||||
|
||||
if (userIds.includes(currentUserId)) {
|
||||
usernames.unshift(allUsernames[currentUserId]);
|
||||
} else if (userIds.includes(currentUsername)) {
|
||||
usernames.unshift(allUsernames[currentUsername]);
|
||||
}
|
||||
|
||||
return usernames;
|
||||
}
|
||||
|
||||
renderFormattedMessage(postType, userIds, actorId, style) {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
textStyles,
|
||||
theme,
|
||||
} = this.props;
|
||||
const usernames = this.getUsernamesByIds(userIds);
|
||||
let actor = actorId ? this.getUsernamesByIds([actorId])[0] : '';
|
||||
if (actor && (actorId === currentUserId || actorId === currentUsername)) {
|
||||
actor = actor.toLowerCase();
|
||||
}
|
||||
|
||||
const firstUser = usernames[0];
|
||||
const secondUser = usernames[1];
|
||||
const numOthers = usernames.length - 1;
|
||||
|
||||
if (numOthers > 1) {
|
||||
return (
|
||||
<LastUsers
|
||||
actor={actor}
|
||||
expandedLocale={postTypeMessage[postType].many_expanded}
|
||||
postType={postType}
|
||||
style={style}
|
||||
textStyles={textStyles}
|
||||
theme={theme}
|
||||
usernames={usernames}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let localeHolder;
|
||||
if (numOthers === 0) {
|
||||
localeHolder = postTypeMessage[postType].one;
|
||||
|
||||
if (
|
||||
(userIds[0] === currentUserId || userIds[0] === currentUsername) &&
|
||||
postTypeMessage[postType].one_you
|
||||
) {
|
||||
localeHolder = postTypeMessage[postType].one_you;
|
||||
}
|
||||
} else if (numOthers === 1) {
|
||||
localeHolder = postTypeMessage[postType].two;
|
||||
}
|
||||
|
||||
const formattedMessage = formatMessage(localeHolder, {firstUser, secondUser, actor});
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
baseTextStyle={style.baseText}
|
||||
textStyles={textStyles}
|
||||
value={formattedMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderMessage(postType, userIds, actorId, style) {
|
||||
return (
|
||||
<React.Fragment key={postType + actorId}>
|
||||
{this.renderFormattedMessage(postType, userIds, actorId, {baseText: style.baseText, linkText: style.linkText})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentUserId,
|
||||
messageData,
|
||||
theme,
|
||||
} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const content = [];
|
||||
const removedUserIds = [];
|
||||
for (const message of messageData) {
|
||||
const {
|
||||
postType,
|
||||
actorId,
|
||||
} = message;
|
||||
let userIds = message.userIds;
|
||||
|
||||
if (!this.props.showJoinLeave && actorId !== currentUserId) {
|
||||
const affectsCurrentUser = userIds.indexOf(currentUserId) !== -1;
|
||||
|
||||
if (affectsCurrentUser) {
|
||||
// Only show the message that the current user was added, etc
|
||||
userIds = [currentUserId];
|
||||
} else {
|
||||
// Not something the current user did or was affected by
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (postType === REMOVE_FROM_CHANNEL) {
|
||||
removedUserIds.push(...userIds);
|
||||
continue;
|
||||
}
|
||||
|
||||
content.push(this.renderMessage(postType, userIds, actorId, style));
|
||||
}
|
||||
|
||||
if (removedUserIds.length > 0) {
|
||||
const uniqueRemovedUserIds = removedUserIds.filter((id, index, arr) => arr.indexOf(id) === index);
|
||||
content.push(this.renderMessage(REMOVE_FROM_CHANNEL, uniqueRemovedUserIds, currentUserId, style));
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{content}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
baseText: {
|
||||
color: theme.centerChannelColor,
|
||||
opacity: 0.6,
|
||||
},
|
||||
linkText: {
|
||||
color: theme.linkColor,
|
||||
opacity: 0.8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
|
||||
import {getMissingProfilesByIds, getMissingProfilesByUsernames} from '@mm-redux/actions/users';
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
import {getBool} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser, makeGetProfilesByIdsAndUsernames} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import CombinedSystemMessage from './combined_system_message';
|
||||
|
||||
function makeMapStateToProps() {
|
||||
const getProfilesByIdsAndUsernames = makeGetProfilesByIdsAndUsernames();
|
||||
|
||||
return (state, ownProps) => {
|
||||
const currentUser = getCurrentUser(state) || {};
|
||||
const {allUserIds, allUsernames} = ownProps;
|
||||
return {
|
||||
currentUserId: currentUser.id,
|
||||
currentUsername: currentUser.username,
|
||||
showJoinLeave: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true),
|
||||
userProfiles: getProfilesByIdsAndUsernames(state, {allUserIds, allUsernames}),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
getMissingProfilesByIds,
|
||||
getMissingProfilesByUsernames,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(CombinedSystemMessage);
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {Text} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
|
||||
import FormattedMarkdownText from 'app/components/formatted_markdown_text';
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import Markdown from 'app/components/markdown';
|
||||
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
||||
const typeMessage = {
|
||||
[Posts.POST_TYPES.ADD_TO_CHANNEL]: {
|
||||
id: t('last_users_message.added_to_channel.type'),
|
||||
defaultMessage: 'were **added to the channel** by {actor}.',
|
||||
},
|
||||
[Posts.POST_TYPES.JOIN_CHANNEL]: {
|
||||
id: t('last_users_message.joined_channel.type'),
|
||||
defaultMessage: '**joined the channel**.',
|
||||
},
|
||||
[Posts.POST_TYPES.LEAVE_CHANNEL]: {
|
||||
id: t('last_users_message.left_channel.type'),
|
||||
defaultMessage: '**left the channel**.',
|
||||
},
|
||||
[Posts.POST_TYPES.REMOVE_FROM_CHANNEL]: {
|
||||
id: t('last_users_message.removed_from_channel.type'),
|
||||
defaultMessage: 'were **removed from the channel**.',
|
||||
},
|
||||
[Posts.POST_TYPES.ADD_TO_TEAM]: {
|
||||
id: t('last_users_message.added_to_team.type'),
|
||||
defaultMessage: 'were **added to the team** by {actor}.',
|
||||
},
|
||||
[Posts.POST_TYPES.JOIN_TEAM]: {
|
||||
id: t('last_users_message.joined_team.type'),
|
||||
defaultMessage: '**joined the team**.',
|
||||
},
|
||||
[Posts.POST_TYPES.LEAVE_TEAM]: {
|
||||
id: t('last_users_message.left_team.type'),
|
||||
defaultMessage: '**left the team**.',
|
||||
},
|
||||
[Posts.POST_TYPES.REMOVE_FROM_TEAM]: {
|
||||
id: t('last_users_message.removed_from_team.type'),
|
||||
defaultMessage: 'were **removed from the team**.',
|
||||
},
|
||||
};
|
||||
|
||||
export default class LastUsers extends React.PureComponent {
|
||||
static propTypes = {
|
||||
actor: PropTypes.string,
|
||||
expandedLocale: PropTypes.object.isRequired,
|
||||
postType: PropTypes.string.isRequired,
|
||||
style: PropTypes.object.isRequired,
|
||||
textStyles: PropTypes.object,
|
||||
theme: PropTypes.object.isRequired,
|
||||
usernames: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
usernames: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
expand: false,
|
||||
};
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
handleOnPress = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
this.setState({expand: true});
|
||||
}
|
||||
|
||||
renderExpandedView = () => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {
|
||||
actor,
|
||||
expandedLocale,
|
||||
style,
|
||||
textStyles,
|
||||
usernames,
|
||||
} = this.props;
|
||||
|
||||
const lastIndex = usernames.length - 1;
|
||||
const lastUser = usernames[lastIndex];
|
||||
|
||||
const formattedMessage = formatMessage(expandedLocale, {
|
||||
users: usernames.slice(0, lastIndex).join(', '),
|
||||
lastUser,
|
||||
actor,
|
||||
});
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
baseTextStyle={style.baseText}
|
||||
textStyles={textStyles}
|
||||
value={formattedMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderCollapsedView = () => {
|
||||
const {
|
||||
actor,
|
||||
postType,
|
||||
style,
|
||||
textStyles,
|
||||
theme,
|
||||
usernames,
|
||||
} = this.props;
|
||||
|
||||
const firstUser = usernames[0];
|
||||
const numOthers = usernames.length - 1;
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<FormattedMarkdownText
|
||||
id={'last_users_message.first'}
|
||||
defaultMessage={'{firstUser} and '}
|
||||
values={{firstUser}}
|
||||
baseTextStyle={style.baseText}
|
||||
style={style.baseText}
|
||||
textStyles={textStyles}
|
||||
theme={theme}
|
||||
/>
|
||||
<Text>{' '}</Text>
|
||||
<Text
|
||||
style={style.linkText}
|
||||
onPress={this.handleOnPress}
|
||||
>
|
||||
<FormattedText
|
||||
id={'last_users_message.others'}
|
||||
defaultMessage={'{numOthers} others '}
|
||||
values={{numOthers}}
|
||||
/>
|
||||
</Text>
|
||||
<FormattedMarkdownText
|
||||
id={typeMessage[postType].id}
|
||||
defaultMessage={typeMessage[postType].defaultMessage}
|
||||
values={{actor}}
|
||||
baseTextStyle={style.baseText}
|
||||
style={style.baseText}
|
||||
textStyles={textStyles}
|
||||
theme={theme}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.expand) {
|
||||
return this.renderExpandedView();
|
||||
}
|
||||
|
||||
return this.renderCollapsedView();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {makeGenerateCombinedPost} from '@mm-redux/utils/post_list';
|
||||
|
||||
import Post from 'app/components/post';
|
||||
|
||||
export function makeMapStateToProps() {
|
||||
const generateCombinedPost = makeGenerateCombinedPost();
|
||||
|
||||
return (state, ownProps) => {
|
||||
return {
|
||||
post: generateCombinedPost(state, ownProps.combinedId),
|
||||
postId: ownProps.combinedId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Note that this also passes through Post's mapStateToProps
|
||||
export default connect(makeMapStateToProps)(Post);
|
||||
|
|
@ -198,7 +198,9 @@ exports[`UserListRow should match snapshot for currentUser with (you) populated
|
|||
}
|
||||
}
|
||||
testID="custom_list.user_item.display_username"
|
||||
/>
|
||||
>
|
||||
@user - you
|
||||
</Text>
|
||||
<BotTag
|
||||
show={false}
|
||||
theme={
|
||||
|
|
@ -410,7 +412,9 @@ exports[`UserListRow should match snapshot for deactivated user 1`] = `
|
|||
"marginTop": 2,
|
||||
}
|
||||
}
|
||||
/>
|
||||
>
|
||||
Deactivated
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CustomListRow>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import UserListRow from './user_list_row';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('@utils/theme', () => {
|
||||
const original = jest.requireActual('../../../utils/theme');
|
||||
return {
|
||||
|
|
@ -18,7 +17,6 @@ jest.mock('@utils/theme', () => {
|
|||
});
|
||||
|
||||
describe('UserListRow', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
id: '123455',
|
||||
isMyUser: false,
|
||||
|
|
@ -33,9 +31,8 @@ describe('UserListRow', () => {
|
|||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -52,9 +49,8 @@ describe('UserListRow', () => {
|
|||
user: deactivatedUser,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...newProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -68,9 +64,8 @@ describe('UserListRow', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...newProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -84,9 +79,8 @@ describe('UserListRow', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...newProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -97,9 +91,8 @@ describe('UserListRow', () => {
|
|||
isMyUser: true,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<UserListRow {...newProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
|||
>
|
||||
<View>
|
||||
<View>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Name"
|
||||
id="channel_modal.name"
|
||||
style={
|
||||
|
|
@ -103,7 +103,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Purpose"
|
||||
id="channel_modal.purpose"
|
||||
style={
|
||||
|
|
@ -114,7 +114,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
/>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="(optional)"
|
||||
id="channel_modal.optional"
|
||||
style={
|
||||
|
|
@ -169,7 +169,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
|||
/>
|
||||
</View>
|
||||
<View>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Describe how this channel should be used."
|
||||
id="channel_modal.descriptionHelp"
|
||||
style={
|
||||
|
|
@ -192,7 +192,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Header"
|
||||
id="channel_modal.header"
|
||||
style={
|
||||
|
|
@ -203,7 +203,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
/>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="(optional)"
|
||||
id="channel_modal.optional"
|
||||
style={
|
||||
|
|
@ -265,7 +265,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="Set text that will appear in the header of the channel beside the channel name. For example, include frequently used links by typing [Link Title](http://example.com)."
|
||||
id="channel_modal.headerHelp"
|
||||
style={
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {General} from '@mm-redux/constants';
|
|||
|
||||
import Autocomplete from 'app/components/autocomplete';
|
||||
import ErrorText from 'app/components/error_text';
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import Loading from 'app/components/loading';
|
||||
import StatusBar from 'app/components/status_bar';
|
||||
import TextInputWithLocalizedPlaceholder from 'app/components/text_input_with_localized_placeholder';
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import Button from 'react-native-button';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {preventDoubleTap} from 'app/utils/tap';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
|
||||
import {getStatusColors} from '@utils/message_attachment_colors';
|
||||
import ButtonBindingText from './button_binding_text';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {ActionResult} from '@mm-redux/types/actions';
|
||||
import {AppBinding} from '@mm-redux/types/apps';
|
||||
import {Post} from '@mm-redux/types/posts';
|
||||
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
|
||||
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
|
||||
import {createCallContext, createCallRequest} from '@utils/apps';
|
||||
import {Channel} from '@mm-redux/types/channels';
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
doAppCall: DoAppCall;
|
||||
getChannel: (channelId: string) => Promise<ActionResult>;
|
||||
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
|
||||
};
|
||||
post: Post;
|
||||
binding: AppBinding;
|
||||
theme: Theme;
|
||||
currentTeamID: string;
|
||||
}
|
||||
export default class ButtonBinding extends PureComponent<Props> {
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
private mounted = false;
|
||||
|
||||
handleActionPress = preventDoubleTap(async () => {
|
||||
const {
|
||||
binding,
|
||||
post,
|
||||
currentTeamID,
|
||||
} = this.props;
|
||||
const intl = this.context.intl;
|
||||
if (!binding.call) {
|
||||
return;
|
||||
}
|
||||
|
||||
let teamID = '';
|
||||
const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any};
|
||||
if (data) {
|
||||
const channel = data as Channel;
|
||||
teamID = channel.team_id;
|
||||
}
|
||||
|
||||
const context = createCallContext(
|
||||
binding.app_id,
|
||||
AppBindingLocations.IN_POST + binding.location,
|
||||
post.channel_id,
|
||||
teamID || currentTeamID,
|
||||
post.id,
|
||||
);
|
||||
const call = createCallRequest(
|
||||
binding.call,
|
||||
context,
|
||||
{post: AppExpandLevels.EXPAND_ALL},
|
||||
);
|
||||
this.setState({executing: true});
|
||||
const res = await this.props.actions.doAppCall(call, AppCallTypes.SUBMIT, this.context.intl);
|
||||
if (this.mounted) {
|
||||
this.setState({executing: false});
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
const errorResponse = res.error;
|
||||
const errorMessage = errorResponse.error || intl.formatMessage(
|
||||
{id: 'apps.error.unknown',
|
||||
defaultMessage: 'Unknown error occurred.',
|
||||
});
|
||||
this.props.actions.postEphemeralCallResponseForPost(errorResponse, errorMessage, post);
|
||||
return;
|
||||
}
|
||||
|
||||
const callResp = res.data!;
|
||||
|
||||
switch (callResp.type) {
|
||||
case AppCallResponseTypes.OK:
|
||||
if (callResp.markdown) {
|
||||
this.props.actions.postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
|
||||
}
|
||||
return;
|
||||
case AppCallResponseTypes.NAVIGATE:
|
||||
case AppCallResponseTypes.FORM:
|
||||
return;
|
||||
default: {
|
||||
const errorMessage = intl.formatMessage(
|
||||
{
|
||||
id: 'apps.error.responses.unknown_type',
|
||||
defaultMessage: 'App response type not supported. Response type: {type}.',
|
||||
},
|
||||
{
|
||||
type: callResp.type,
|
||||
},
|
||||
);
|
||||
this.props.actions.postEphemeralCallResponseForPost(callResp, errorMessage, post);
|
||||
}
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {theme, binding} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<Button
|
||||
containerStyle={[style.button]}
|
||||
disabledContainerStyle={style.buttonDisabled}
|
||||
onPress={this.handleActionPress}
|
||||
>
|
||||
<ButtonBindingText
|
||||
message={binding.label}
|
||||
style={style.text}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
const STATUS_COLORS = getStatusColors(theme);
|
||||
return {
|
||||
button: {
|
||||
borderRadius: 4,
|
||||
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
|
||||
borderWidth: 2,
|
||||
opacity: 1,
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
justifyContent: 'center',
|
||||
height: 36,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
|
||||
},
|
||||
text: {
|
||||
color: STATUS_COLORS.default,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
lineHeight: 17,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
|
||||
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
|
||||
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
|
||||
import {getPost} from '@mm-redux/selectors/entities/posts';
|
||||
|
||||
import ButtonBinding from './button_binding';
|
||||
import {getChannel} from '@mm-redux/actions/channels';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
|
||||
type OwnProps = {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
return {
|
||||
theme: getTheme(state),
|
||||
post: getPost(state, ownProps.postId),
|
||||
currentTeamID: getCurrentTeamId(state),
|
||||
};
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
doAppCall: DoAppCall;
|
||||
getChannel: (channelId: string) => Promise<ActionResult>;
|
||||
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
|
||||
doAppCall,
|
||||
getChannel,
|
||||
postEphemeralCallResponseForPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ButtonBinding);
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {LayoutChangeEvent, ScrollView, StyleProp, View} from 'react-native';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import ShowMoreButton from '@components/show_more_button';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
const SHOW_MORE_HEIGHT = 60;
|
||||
|
||||
type Props = {
|
||||
baseTextStyle: StyleProp<any>,
|
||||
blockStyles?: StyleProp<any>[],
|
||||
deviceHeight: number,
|
||||
onPermalinkPress?: () => void,
|
||||
textStyles?: StyleProp<any>[],
|
||||
theme?: Theme,
|
||||
value?: string,
|
||||
}
|
||||
|
||||
type State = {
|
||||
collapsed: boolean;
|
||||
isLongText: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export default class EmbedText extends PureComponent<Props, State> {
|
||||
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
|
||||
const {deviceHeight} = nextProps;
|
||||
const maxHeight = Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
|
||||
|
||||
if (maxHeight !== prevState.maxHeight) {
|
||||
return {
|
||||
maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
collapsed: true,
|
||||
isLongText: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleLayout = (event: LayoutChangeEvent) => {
|
||||
const {height} = event.nativeEvent.layout;
|
||||
const {maxHeight} = this.state;
|
||||
|
||||
if (height >= (maxHeight || 0)) {
|
||||
this.setState({
|
||||
isLongText: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapseState = () => {
|
||||
const {collapsed} = this.state;
|
||||
this.setState({collapsed: !collapsed});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
onPermalinkPress,
|
||||
textStyles,
|
||||
theme,
|
||||
value,
|
||||
} = this.props;
|
||||
const {collapsed, isLongText, maxHeight} = this.state;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
style={{maxHeight: (collapsed ? maxHeight : undefined), overflow: 'hidden'}}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
onLayout={this.handleLayout}
|
||||
removeClippedSubviews={isLongText && collapsed}
|
||||
>
|
||||
<Markdown
|
||||
baseTextStyle={baseTextStyle}
|
||||
textStyles={textStyles}
|
||||
blockStyles={blockStyles}
|
||||
disableGallery={true}
|
||||
value={value}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{isLongText &&
|
||||
<ShowMoreButton
|
||||
onPress={this.toggleCollapseState}
|
||||
showMore={collapsed}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import EmbeddedBinding from './embedded_binding';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {AppBinding} from '@mm-redux/types/apps';
|
||||
|
||||
type Props = {
|
||||
embeds: AppBinding[],
|
||||
baseTextStyle?: StyleProp<TextStyle>,
|
||||
blockStyles?: StyleProp<ViewStyle>[],
|
||||
deviceHeight: number,
|
||||
deviceWidth: number,
|
||||
postId: string,
|
||||
onPermalinkPress?: () => void,
|
||||
theme: Theme,
|
||||
textStyles?: StyleProp<TextStyle>[],
|
||||
}
|
||||
|
||||
export default function EmbeddedBindings(props: Props) {
|
||||
const {
|
||||
embeds,
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
deviceHeight,
|
||||
onPermalinkPress,
|
||||
postId,
|
||||
theme,
|
||||
textStyles,
|
||||
} = props;
|
||||
const content = [] as React.ReactNode[];
|
||||
|
||||
embeds.forEach((embed, i) => {
|
||||
content.push(
|
||||
<EmbeddedBinding
|
||||
embed={embed}
|
||||
baseTextStyle={baseTextStyle}
|
||||
blockStyles={blockStyles}
|
||||
deviceHeight={deviceHeight}
|
||||
key={'att_' + i}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
postId={postId}
|
||||
theme={theme}
|
||||
textStyles={textStyles}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{flex: 1, flexDirection: 'column'}}>
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
|
||||
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
|
||||
import {getPost} from '@mm-redux/selectors/entities/posts';
|
||||
|
||||
import MenuBinding from './menu_binding';
|
||||
import {getChannel} from '@mm-redux/actions/channels';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
|
||||
|
||||
type OwnProps = {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
return {
|
||||
post: getPost(state, ownProps.postId),
|
||||
currentTeamID: getCurrentTeamId(state),
|
||||
};
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
doAppCall: DoAppCall;
|
||||
getChannel: (channelId: string) => Promise<ActionResult>;
|
||||
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
|
||||
doAppCall,
|
||||
getChannel,
|
||||
postEphemeralCallResponseForPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(MenuBinding);
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
|
||||
import AutocompleteSelector from 'app/components/autocomplete_selector';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {PostActionOption} from '@mm-redux/types/integration_actions';
|
||||
import {Post} from '@mm-redux/types/posts';
|
||||
import {AppBinding} from '@mm-redux/types/apps';
|
||||
import {ActionResult} from '@mm-redux/types/actions';
|
||||
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
|
||||
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
|
||||
import {Channel} from '@mm-redux/types/channels';
|
||||
import {createCallContext, createCallRequest} from '@utils/apps';
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
doAppCall: DoAppCall;
|
||||
getChannel: (channelId: string) => Promise<ActionResult>;
|
||||
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
|
||||
};
|
||||
binding?: AppBinding;
|
||||
post: Post;
|
||||
currentTeamID: string;
|
||||
}
|
||||
|
||||
type State = {
|
||||
selected?: PostActionOption;
|
||||
}
|
||||
|
||||
export default class MenuBinding extends PureComponent<Props, State> {
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
handleSelect = async (selected?: PostActionOption) => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({selected});
|
||||
const binding = this.props.binding?.bindings?.find((b) => b.location === selected.value);
|
||||
if (!binding) {
|
||||
console.debug('Trying to select element not present in binding.'); //eslint-disable-line no-console
|
||||
return;
|
||||
}
|
||||
|
||||
if (!binding.call) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
actions,
|
||||
post,
|
||||
currentTeamID,
|
||||
} = this.props;
|
||||
const intl = this.context.intl;
|
||||
|
||||
let teamID = '';
|
||||
const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any};
|
||||
if (data) {
|
||||
const channel = data as Channel;
|
||||
teamID = channel.team_id;
|
||||
}
|
||||
|
||||
const context = createCallContext(
|
||||
binding.app_id,
|
||||
AppBindingLocations.IN_POST + binding.location,
|
||||
post.channel_id,
|
||||
teamID || currentTeamID,
|
||||
post.id,
|
||||
);
|
||||
const call = createCallRequest(
|
||||
binding.call,
|
||||
context,
|
||||
{post: AppExpandLevels.EXPAND_ALL},
|
||||
);
|
||||
|
||||
const res = await actions.doAppCall(call, AppCallTypes.SUBMIT, intl);
|
||||
if (res.error) {
|
||||
const errorResponse = res.error;
|
||||
const errorMessage = errorResponse.error || intl.formatMessage({
|
||||
id: 'apps.error.unknown',
|
||||
defaultMessage: 'Unknown error occurred.',
|
||||
});
|
||||
this.props.actions.postEphemeralCallResponseForPost(res.error, errorMessage, post);
|
||||
return;
|
||||
}
|
||||
|
||||
const callResp = res.data!;
|
||||
switch (callResp.type) {
|
||||
case AppCallResponseTypes.OK:
|
||||
if (callResp.markdown) {
|
||||
this.props.actions.postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
|
||||
}
|
||||
return;
|
||||
case AppCallResponseTypes.NAVIGATE:
|
||||
case AppCallResponseTypes.FORM:
|
||||
return;
|
||||
default: {
|
||||
const errorMessage = intl.formatMessage({
|
||||
id: 'apps.error.responses.unknown_type',
|
||||
defaultMessage: 'App response type not supported. Response type: {type}.',
|
||||
}, {
|
||||
type: callResp.type,
|
||||
});
|
||||
this.props.actions.postEphemeralCallResponseForPost(callResp, errorMessage, post);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
binding,
|
||||
} = this.props;
|
||||
const {selected} = this.state;
|
||||
|
||||
const options = binding?.bindings?.map<PostActionOption>((b:AppBinding) => {
|
||||
return {text: b.label, value: b.location || ''};
|
||||
});
|
||||
|
||||
return (
|
||||
<AutocompleteSelector
|
||||
placeholder={binding?.label}
|
||||
options={options}
|
||||
selected={selected}
|
||||
onSelected={this.handleSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import React, {PureComponent} from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import {Text} from 'react-native';
|
||||
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {GlobalStyles} from 'app/styles';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FileAttachment should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"width": undefined,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<FileAttachmentImage
|
||||
file={
|
||||
Object {
|
||||
"create_at": 1546893090093,
|
||||
"data": Object {
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
"delete_at": 0,
|
||||
"extension": "png",
|
||||
"has_preview_image": true,
|
||||
"height": 171,
|
||||
"id": "fileId",
|
||||
"name": "image.png",
|
||||
"post_id": "postId",
|
||||
"size": 14894,
|
||||
"update_at": 1546893090093,
|
||||
"user_id": "userId",
|
||||
"width": 425,
|
||||
}
|
||||
}
|
||||
imageDimensions={null}
|
||||
resizeMethod="resize"
|
||||
resizeMode="cover"
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FileAttachmentImage should match snapshot 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderRadius": 5,
|
||||
"overflow": "hidden",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"paddingBottom": "100%",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Connect(ProgressiveImage)
|
||||
onError={[Function]}
|
||||
resizeMethod="resize"
|
||||
resizeMode="cover"
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
tintDefaultSource={true}
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,288 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Dimensions,
|
||||
PixelRatio,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
|
||||
import * as Utils from '@mm-redux/utils/file_utils';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {isDocument, isImage} from '@utils/file';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import FileAttachmentDocument from './file_attachment_document';
|
||||
import FileAttachmentIcon from './file_attachment_icon';
|
||||
import FileAttachmentImage from './file_attachment_image';
|
||||
|
||||
export default class FileAttachment extends PureComponent {
|
||||
static propTypes = {
|
||||
canDownloadFiles: PropTypes.bool.isRequired,
|
||||
file: PropTypes.object.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
onLongPress: PropTypes.func,
|
||||
onPreviewPress: PropTypes.func,
|
||||
theme: PropTypes.object.isRequired,
|
||||
wrapperWidth: PropTypes.number,
|
||||
isSingleImage: PropTypes.bool,
|
||||
nonVisibleImagesCount: PropTypes.number,
|
||||
inViewPort: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onPreviewPress: () => true,
|
||||
wrapperWidth: 300,
|
||||
};
|
||||
|
||||
state = {
|
||||
resizeMode: 'cover',
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.transition) {
|
||||
clearTimeout(this.transition);
|
||||
}
|
||||
}
|
||||
|
||||
handlePress = () => {
|
||||
this.props.onPreviewPress(this.props.index);
|
||||
};
|
||||
|
||||
handlePreviewPress = () => {
|
||||
if (this.documentElement) {
|
||||
this.documentElement.handlePreviewPress();
|
||||
} else {
|
||||
this.props.onPreviewPress(this.props.index);
|
||||
}
|
||||
};
|
||||
|
||||
renderFileInfo() {
|
||||
const {file, onLongPress, theme} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
if (!file?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePress}
|
||||
onLongPress={onLongPress}
|
||||
type={'opacity'}
|
||||
style={style.attachmentContainer}
|
||||
>
|
||||
<React.Fragment>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.fileName}
|
||||
>
|
||||
{file.name.trim()}
|
||||
</Text>
|
||||
<View style={style.fileDownloadContainer}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.fileInfo}
|
||||
>
|
||||
{`${Utils.getFormattedFileSize(file)}`}
|
||||
</Text>
|
||||
</View>
|
||||
</React.Fragment>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
setDocumentRef = (ref) => {
|
||||
this.documentElement = ref;
|
||||
};
|
||||
|
||||
renderMoreImagesOverlay = (value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {theme} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View style={style.moreImagesWrapper}>
|
||||
<Text style={style.moreImagesText}>
|
||||
{`+${value}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
getImageDimensions = (file) => {
|
||||
const {isSingleImage, wrapperWidth} = this.props;
|
||||
const viewPortHeight = this.getViewPortHeight();
|
||||
|
||||
if (isSingleImage) {
|
||||
return calculateDimensions(file?.height, file?.width, wrapperWidth, viewPortHeight);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getViewPortHeight = () => {
|
||||
const dimensions = Dimensions.get('window');
|
||||
const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
|
||||
|
||||
return viewPortHeight;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
canDownloadFiles,
|
||||
file,
|
||||
theme,
|
||||
onLongPress,
|
||||
isSingleImage,
|
||||
nonVisibleImagesCount,
|
||||
inViewPort,
|
||||
} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
let fileAttachmentComponent;
|
||||
if (isImage(file) || file.loading) {
|
||||
const imageDimensions = this.getImageDimensions(file);
|
||||
|
||||
fileAttachmentComponent = (
|
||||
<TouchableWithFeedback
|
||||
key={`${this.props.id}${file.loading}`}
|
||||
onPress={this.handlePreviewPress}
|
||||
onLongPress={onLongPress}
|
||||
type={'opacity'}
|
||||
style={{width: imageDimensions?.width}}
|
||||
>
|
||||
<FileAttachmentImage
|
||||
file={file || {}}
|
||||
inViewPort={inViewPort}
|
||||
imageDimensions={imageDimensions}
|
||||
isSingleImage={isSingleImage}
|
||||
resizeMode={this.state.resizeMode}
|
||||
theme={theme}
|
||||
/>
|
||||
{this.renderMoreImagesOverlay(nonVisibleImagesCount, theme)}
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
} else if (isDocument(file)) {
|
||||
fileAttachmentComponent = (
|
||||
<View style={[style.fileWrapper]}>
|
||||
<View style={style.iconWrapper}>
|
||||
<FileAttachmentDocument
|
||||
ref={this.setDocumentRef}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file}
|
||||
onLongPress={onLongPress}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
{this.renderFileInfo()}
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
fileAttachmentComponent = (
|
||||
<View style={[style.fileWrapper]}>
|
||||
<View style={style.iconWrapper}>
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePreviewPress}
|
||||
onLongPress={onLongPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
<FileAttachmentIcon
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
{this.renderFileInfo()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return fileAttachmentComponent;
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
const scale = Dimensions.get('window').width / 320;
|
||||
|
||||
return {
|
||||
attachmentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
downloadIcon: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.7),
|
||||
marginRight: 5,
|
||||
},
|
||||
fileDownloadContainer: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 3,
|
||||
},
|
||||
fileInfo: {
|
||||
fontSize: 14,
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
fileName: {
|
||||
flexDirection: 'column',
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: theme.centerChannelColor,
|
||||
paddingRight: 10,
|
||||
},
|
||||
fileWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
marginTop: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
|
||||
borderRadius: 5,
|
||||
},
|
||||
iconWrapper: {
|
||||
marginTop: 7.8,
|
||||
marginRight: 6,
|
||||
marginBottom: 8.2,
|
||||
marginLeft: 8,
|
||||
},
|
||||
circularProgress: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
circularProgressContent: {
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
moreImagesWrapper: {
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 5,
|
||||
},
|
||||
moreImagesText: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)),
|
||||
fontFamily: 'Open Sans',
|
||||
textAlign: 'center',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import FileAttachment from './file_attachment.js';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
jest.mock('react-native-file-viewer', () => ({
|
||||
open: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('FileAttachment', () => {
|
||||
const baseProps = {
|
||||
canDownloadFiles: true,
|
||||
file: {
|
||||
create_at: 1546893090093,
|
||||
delete_at: 0,
|
||||
extension: 'png',
|
||||
has_preview_image: true,
|
||||
height: 171,
|
||||
id: 'fileId',
|
||||
name: 'image.png',
|
||||
post_id: 'postId',
|
||||
size: 14894,
|
||||
update_at: 1546893090093,
|
||||
user_id: 'userId',
|
||||
width: 425,
|
||||
data: {
|
||||
mime_type: 'image/png',
|
||||
},
|
||||
},
|
||||
id: 'id',
|
||||
index: 0,
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import FileViewer from 'react-native-file-viewer';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import {intlShape} from 'react-intl';
|
||||
import tinyColor from 'tinycolor2';
|
||||
|
||||
import FileAttachmentIcon from '@components/file_attachment_list/file_attachment_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import {DeviceTypes} from '@constants/';
|
||||
import {getFileUrl} from '@mm-redux/utils/file_utils';
|
||||
import {getLocalFilePathFromFile} from '@utils/file';
|
||||
import mattermostBucket from 'app/mattermost_bucket';
|
||||
|
||||
const {DOCUMENTS_PATH} = DeviceTypes;
|
||||
|
||||
export default class FileAttachmentDocument extends PureComponent {
|
||||
static propTypes = {
|
||||
backgroundColor: PropTypes.string,
|
||||
canDownloadFiles: PropTypes.bool.isRequired,
|
||||
file: PropTypes.object.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
onLongPress: PropTypes.func,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
state = {
|
||||
didCancel: false,
|
||||
downloading: false,
|
||||
preview: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
cancelDownload = () => {
|
||||
if (this.mounted) {
|
||||
this.setState({didCancel: true});
|
||||
}
|
||||
|
||||
if (this.downloadTask) {
|
||||
this.downloadTask.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
setStatusBarColor = (style) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (style) {
|
||||
StatusBar.setBarStyle(style, true);
|
||||
} else {
|
||||
const {theme} = this.props;
|
||||
const headerColor = tinyColor(theme.sidebarHeaderBg);
|
||||
let barStyle = 'light-content';
|
||||
if (headerColor.isLight() && Platform.OS === 'ios') {
|
||||
barStyle = 'dark-content';
|
||||
}
|
||||
StatusBar.setBarStyle(barStyle, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
downloadAndPreviewFile = async (file) => {
|
||||
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, file);
|
||||
|
||||
this.setState({didCancel: false});
|
||||
|
||||
try {
|
||||
const certificate = await mattermostBucket.getPreference('cert');
|
||||
const isDir = await RNFetchBlob.fs.isDir(DOCUMENTS_PATH);
|
||||
if (!isDir) {
|
||||
try {
|
||||
await RNFetchBlob.fs.mkdir(DOCUMENTS_PATH);
|
||||
} catch (error) {
|
||||
this.showDownloadFailedAlert();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
session: file.id,
|
||||
timeout: 10000,
|
||||
indicator: true,
|
||||
overwrite: true,
|
||||
path,
|
||||
certificate,
|
||||
};
|
||||
|
||||
const exist = await RNFetchBlob.fs.exists(path);
|
||||
if (exist) {
|
||||
this.openDocument(file);
|
||||
} else {
|
||||
this.setState({downloading: true});
|
||||
this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(file.id));
|
||||
this.downloadTask.progress((received, total) => {
|
||||
const progress = parseFloat((received / total).toFixed(1));
|
||||
if (this.mounted) {
|
||||
this.setState({progress});
|
||||
}
|
||||
});
|
||||
|
||||
await this.downloadTask;
|
||||
if (this.mounted) {
|
||||
this.setState({
|
||||
progress: 1,
|
||||
}, () => {
|
||||
this.openDocument(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
RNFetchBlob.fs.unlink(path);
|
||||
if (this.mounted) {
|
||||
this.setState({downloading: false, progress: 0});
|
||||
|
||||
if (error.message !== 'cancelled') {
|
||||
this.showDownloadFailedAlert();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handlePreviewPress = async () => {
|
||||
const {canDownloadFiles, file} = this.props;
|
||||
const {downloading, progress} = this.state;
|
||||
|
||||
if (!canDownloadFiles) {
|
||||
this.showDownloadDisabledAlert();
|
||||
return;
|
||||
}
|
||||
|
||||
if (downloading && progress < 1) {
|
||||
this.cancelDownload();
|
||||
} else if (downloading) {
|
||||
this.resetViewState();
|
||||
} else {
|
||||
this.downloadAndPreviewFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
onDonePreviewingFile = () => {
|
||||
if (this.mounted) {
|
||||
this.setState({progress: 0, downloading: false, preview: false});
|
||||
}
|
||||
this.setStatusBarColor();
|
||||
};
|
||||
|
||||
openDocument = (file) => {
|
||||
if (!this.state.didCancel && !this.state.preview && this.mounted) {
|
||||
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, file);
|
||||
this.setState({preview: true});
|
||||
this.setStatusBarColor('dark-content');
|
||||
FileViewer.open(path, {
|
||||
displayName: file.name,
|
||||
onDismiss: this.onDonePreviewingFile,
|
||||
showOpenWithDialog: true,
|
||||
showAppsSuggestions: true,
|
||||
}).then(() => {
|
||||
if (this.mounted) {
|
||||
this.setState({downloading: false, progress: 0});
|
||||
}
|
||||
}).catch(() => {
|
||||
const {intl} = this.context;
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.document_preview.failed_title',
|
||||
defaultMessage: 'Open Document failed',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.document_preview.failed_description',
|
||||
defaultMessage: 'An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n',
|
||||
}, {
|
||||
fileType: file.extension.toUpperCase(),
|
||||
}),
|
||||
[{
|
||||
text: intl.formatMessage({
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}],
|
||||
);
|
||||
this.onDonePreviewingFile();
|
||||
RNFetchBlob.fs.unlink(path);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
resetViewState = () => {
|
||||
if (this.mounted) {
|
||||
this.setState({
|
||||
progress: 0,
|
||||
didCancel: true,
|
||||
downloading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
showDownloadDisabledAlert = () => {
|
||||
const {intl} = this.context;
|
||||
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.downloader.disabled_title',
|
||||
defaultMessage: 'Download disabled',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.downloader.disabled_description',
|
||||
defaultMessage: 'File downloads are disabled on this server. Please contact your System Admin for more details.\n',
|
||||
}),
|
||||
[{
|
||||
text: intl.formatMessage({
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}],
|
||||
);
|
||||
};
|
||||
|
||||
showDownloadFailedAlert = () => {
|
||||
const {intl} = this.context;
|
||||
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.downloader.failed_title',
|
||||
defaultMessage: 'Download failed',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.downloader.failed_description',
|
||||
defaultMessage: 'An error occurred while downloading the file. Please check your internet connection and try again.\n',
|
||||
}),
|
||||
[{
|
||||
text: intl.formatMessage({
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}],
|
||||
);
|
||||
};
|
||||
|
||||
renderFileAttachmentIcon = () => {
|
||||
const {backgroundColor, file, theme} = this.props;
|
||||
|
||||
return (
|
||||
<FileAttachmentIcon
|
||||
backgroundColor={backgroundColor}
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {onLongPress, theme} = this.props;
|
||||
const {downloading, progress} = this.state;
|
||||
let fileAttachmentComponent;
|
||||
if (downloading) {
|
||||
fileAttachmentComponent = (
|
||||
<>
|
||||
{this.renderFileAttachmentIcon()}
|
||||
<View style={[StyleSheet.absoluteFill, styles.progress]}>
|
||||
<ProgressBar
|
||||
progress={progress || 0.1}
|
||||
color={theme.buttonBg}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
fileAttachmentComponent = this.renderFileAttachmentIcon();
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePreviewPress}
|
||||
onLongPress={onLongPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
{fileAttachmentComponent}
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
progress: {
|
||||
justifyContent: 'flex-end',
|
||||
height: 48,
|
||||
left: 2,
|
||||
top: 5,
|
||||
width: 44,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {View, StyleSheet} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import * as Utils from '@mm-redux/utils/file_utils';
|
||||
|
||||
const BLUE_ICON = '#338AFF';
|
||||
const RED_ICON = '#ED522A';
|
||||
const GREEN_ICON = '#1CA660';
|
||||
const GRAY_ICON = '#999999';
|
||||
const FAILED_ICON_NAME_AND_COLOR = ['jumbo-attachment-image-broken', GRAY_ICON];
|
||||
const ICON_NAME_AND_COLOR_FROM_FILE_TYPE = {
|
||||
audio: ['jumbo-attachment-audio', BLUE_ICON],
|
||||
code: ['jumbo-attachment-code', BLUE_ICON],
|
||||
image: ['jumbo-attachment-image', BLUE_ICON],
|
||||
smallImage: ['image-outline', BLUE_ICON],
|
||||
other: ['jumbo-attachment-generic', BLUE_ICON],
|
||||
patch: ['jumbo-attachment-patch', BLUE_ICON],
|
||||
pdf: ['jumbo-attachment-pdf', RED_ICON],
|
||||
presentation: ['jumbo-attachment-powerpoint', RED_ICON],
|
||||
spreadsheet: ['jumbo-attachment-excel', GREEN_ICON],
|
||||
text: ['jumbo-attachment-text', GRAY_ICON],
|
||||
video: ['jumbo-attachment-video', BLUE_ICON],
|
||||
word: ['jumbo-attachment-word', BLUE_ICON],
|
||||
zip: ['jumbo-attachment-zip', BLUE_ICON],
|
||||
};
|
||||
|
||||
export default class FileAttachmentIcon extends PureComponent {
|
||||
static propTypes = {
|
||||
backgroundColor: PropTypes.string,
|
||||
failed: PropTypes.bool,
|
||||
defaultImage: PropTypes.bool,
|
||||
smallImage: PropTypes.bool,
|
||||
file: PropTypes.object,
|
||||
iconColor: PropTypes.string,
|
||||
iconSize: PropTypes.number,
|
||||
theme: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
failed: false,
|
||||
defaultImage: false,
|
||||
smallImage: false,
|
||||
iconSize: 48,
|
||||
};
|
||||
|
||||
getFileIconNameAndColor(file) {
|
||||
if (this.props.failed) {
|
||||
return FAILED_ICON_NAME_AND_COLOR;
|
||||
}
|
||||
|
||||
if (this.props.defaultImage) {
|
||||
if (this.props.smallImage) {
|
||||
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.smallImage;
|
||||
}
|
||||
|
||||
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.image;
|
||||
}
|
||||
|
||||
const fileType = Utils.getFileType(file);
|
||||
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE[fileType] || ICON_NAME_AND_COLOR_FROM_FILE_TYPE.other;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {backgroundColor, file, iconSize, theme, iconColor} = this.props;
|
||||
const [iconName, defaultIconColor] = this.getFileIconNameAndColor(file);
|
||||
const color = iconColor || defaultIconColor;
|
||||
const bgColor = backgroundColor || theme?.centerChannelBg || 'transparent';
|
||||
|
||||
return (
|
||||
<View style={[styles.fileIconWrapper, {backgroundColor: bgColor}]}>
|
||||
<CompassIcon
|
||||
name={iconName}
|
||||
size={iconSize}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fileIconWrapper: {
|
||||
borderRadius: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import {Client4} from '@client/rest';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import FileAttachmentIcon from './file_attachment_icon';
|
||||
|
||||
const SMALL_IMAGE_MAX_HEIGHT = 48;
|
||||
const SMALL_IMAGE_MAX_WIDTH = 48;
|
||||
|
||||
const IMAGE_SIZE = {
|
||||
Fullsize: 'fullsize',
|
||||
Preview: 'preview',
|
||||
Thumbnail: 'thumbnail',
|
||||
};
|
||||
|
||||
export default class FileAttachmentImage extends PureComponent {
|
||||
static propTypes = {
|
||||
file: PropTypes.object.isRequired,
|
||||
imageHeight: PropTypes.number,
|
||||
imageSize: PropTypes.oneOf([
|
||||
IMAGE_SIZE.Fullsize,
|
||||
IMAGE_SIZE.Preview,
|
||||
IMAGE_SIZE.Thumbnail,
|
||||
]),
|
||||
imageWidth: PropTypes.number,
|
||||
theme: PropTypes.object,
|
||||
resizeMode: PropTypes.string,
|
||||
resizeMethod: PropTypes.string,
|
||||
isSingleImage: PropTypes.bool,
|
||||
imageDimensions: PropTypes.object,
|
||||
backgroundColor: PropTypes.string,
|
||||
inViewPort: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
resizeMode: 'cover',
|
||||
resizeMethod: 'resize',
|
||||
};
|
||||
|
||||
state = {
|
||||
failed: false,
|
||||
};
|
||||
|
||||
boxPlaceholder = () => {
|
||||
if (this.props.isSingleImage) {
|
||||
return null;
|
||||
}
|
||||
return (<View style={style.boxPlaceholder}/>);
|
||||
};
|
||||
|
||||
handleError = () => {
|
||||
this.setState({failed: true});
|
||||
}
|
||||
|
||||
imageProps = (file) => {
|
||||
const imageProps = {};
|
||||
|
||||
if (file.localPath) {
|
||||
imageProps.defaultSource = {uri: file.localPath};
|
||||
} else if (file.id) {
|
||||
if (file.mini_preview && file.mime_type) {
|
||||
imageProps.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`;
|
||||
} else {
|
||||
imageProps.thumbnailUri = Client4.getFileThumbnailUrl(file.id);
|
||||
}
|
||||
imageProps.imageUri = Client4.getFilePreviewUrl(file.id);
|
||||
imageProps.inViewPort = this.props.inViewPort;
|
||||
}
|
||||
return imageProps;
|
||||
};
|
||||
|
||||
renderSmallImage = () => {
|
||||
const {file, isSingleImage, resizeMethod, theme} = this.props;
|
||||
|
||||
let wrapperStyle = style.fileImageWrapper;
|
||||
|
||||
if (isSingleImage) {
|
||||
wrapperStyle = style.singleSmallImageWrapper;
|
||||
|
||||
if (file.width > SMALL_IMAGE_MAX_WIDTH) {
|
||||
wrapperStyle = [wrapperStyle, {width: '100%'}];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
wrapperStyle,
|
||||
style.smallImageBorder,
|
||||
{
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{this.boxPlaceholder()}
|
||||
<View style={style.smallImageOverlay}>
|
||||
<ProgressiveImage
|
||||
id={file.id}
|
||||
style={{height: file.height, width: file.width}}
|
||||
tintDefaultSource={!file.localPath && !this.state.failed}
|
||||
filename={file.name}
|
||||
onError={this.handleError}
|
||||
resizeMode={'contain'}
|
||||
resizeMethod={resizeMethod}
|
||||
{...this.imageProps(file)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {failed} = this.state;
|
||||
const {
|
||||
file,
|
||||
imageDimensions,
|
||||
resizeMethod,
|
||||
resizeMode,
|
||||
backgroundColor,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<FileAttachmentIcon
|
||||
failed={failed}
|
||||
backgroundColor={backgroundColor}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) {
|
||||
return this.renderSmallImage();
|
||||
}
|
||||
|
||||
const imageProps = this.imageProps(file);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={style.fileImageWrapper}
|
||||
>
|
||||
{this.boxPlaceholder()}
|
||||
<ProgressiveImage
|
||||
id={file.id}
|
||||
style={[this.props.isSingleImage ? null : style.imagePreview, imageDimensions]}
|
||||
tintDefaultSource={!file.localPath && !this.state.failed}
|
||||
filename={file.name}
|
||||
onError={this.handleError}
|
||||
resizeMode={resizeMode}
|
||||
resizeMethod={resizeMethod}
|
||||
{...imageProps}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
imagePreview: {
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
fileImageWrapper: {
|
||||
borderRadius: 5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
boxPlaceholder: {
|
||||
paddingBottom: '100%',
|
||||
},
|
||||
smallImageBorder: {
|
||||
borderRadius: 5,
|
||||
},
|
||||
smallImageOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
singleSmallImageWrapper: {
|
||||
height: SMALL_IMAGE_MAX_HEIGHT,
|
||||
width: SMALL_IMAGE_MAX_WIDTH,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import {Client4} from '@client/rest';
|
||||
|
||||
import FileAttachmentImage from './file_attachment_image.js';
|
||||
|
||||
describe('FileAttachmentImage', () => {
|
||||
const baseProps = {
|
||||
file: {},
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachmentImage {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('imageProps', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachmentImage {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
it('should have file.localPath as defaultSource if localPath is set', () => {
|
||||
wrapper.setState({failed: false});
|
||||
const file = {localPath: '/localPath.png'};
|
||||
const imageProps = instance.imageProps(file);
|
||||
expect(imageProps.defaultSource).toStrictEqual({uri: file.localPath});
|
||||
expect(imageProps.thumbnailUri).toBeUndefined();
|
||||
expect(imageProps.imageUri).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have thumbnailUri and imageUri if the file has an ID', () => {
|
||||
const getFileThumbnailUrl = jest.spyOn(Client4, 'getFileThumbnailUrl');
|
||||
const getFilePreviewUrl = jest.spyOn(Client4, 'getFilePreviewUrl');
|
||||
|
||||
wrapper.setState({failed: false});
|
||||
const file = {id: 'id'};
|
||||
const imageProps = instance.imageProps(file);
|
||||
expect(getFileThumbnailUrl).toHaveBeenCalled();
|
||||
expect(getFilePreviewUrl).toHaveBeenCalled();
|
||||
expect(imageProps.defaultSource).toBeUndefined();
|
||||
expect(imageProps.thumbnailUri).toBeDefined();
|
||||
expect(imageProps.imageUri).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {StyleSheet, View, DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import ImageViewPort from '@components/image_viewport';
|
||||
import {Client4} from '@client/rest';
|
||||
import {isDocument, isGif, isImage, isVideo} from '@utils/file';
|
||||
import {getViewPortWidth, openGalleryAtIndex} from '@utils/images';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
|
||||
import FileAttachment from './file_attachment';
|
||||
|
||||
const MAX_VISIBLE_ROW_IMAGES = 4;
|
||||
|
||||
export default class FileAttachmentList extends ImageViewPort {
|
||||
static propTypes = {
|
||||
canDownloadFiles: PropTypes.bool.isRequired,
|
||||
fileIds: PropTypes.array.isRequired,
|
||||
files: PropTypes.array,
|
||||
isFailed: PropTypes.bool,
|
||||
onLongPress: PropTypes.func,
|
||||
postId: PropTypes.string.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
isReplyPost: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
files: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.filesForGallery = this.getFilesForGallery(props);
|
||||
|
||||
this.buildGalleryFiles().then((results) => {
|
||||
this.galleryFiles = results;
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
super.componentDidMount();
|
||||
this.onScrollEnd = DeviceEventEmitter.addListener('scrolled', (viewableItems) => {
|
||||
if (this.props.postId in viewableItems) {
|
||||
this.setState({
|
||||
inViewPort: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.files.length !== this.props.files.length) {
|
||||
this.filesForGallery = this.getFilesForGallery(this.props);
|
||||
this.buildGalleryFiles().then((results) => {
|
||||
this.galleryFiles = results;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
super.componentWillUnmount();
|
||||
if (this.onScrollEnd && this.onScrollEnd.remove) {
|
||||
this.onScrollEnd.remove();
|
||||
}
|
||||
}
|
||||
|
||||
attachmentIndex = (fileId) => {
|
||||
return this.filesForGallery.findIndex((file) => file.id === fileId) || 0;
|
||||
};
|
||||
|
||||
attachmentManifest = (attachments) => {
|
||||
return attachments.reduce((info, file) => {
|
||||
if (isImage(file)) {
|
||||
info.imageAttachments.push(file);
|
||||
} else {
|
||||
info.nonImageAttachments.push(file);
|
||||
}
|
||||
return info;
|
||||
}, {imageAttachments: [], nonImageAttachments: []});
|
||||
};
|
||||
|
||||
buildGalleryFiles = async () => {
|
||||
const results = [];
|
||||
|
||||
if (this.filesForGallery && this.filesForGallery.length) {
|
||||
for (let i = 0; i < this.filesForGallery.length; i++) {
|
||||
const file = this.filesForGallery[i];
|
||||
if (isDocument(file) || isVideo(file) || (!isImage(file))) {
|
||||
results.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
let uri;
|
||||
if (file.localPath) {
|
||||
uri = file.localPath;
|
||||
} else {
|
||||
uri = isGif(file) ? Client4.getFileUrl(file.id) : Client4.getFilePreviewUrl(file.id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
...file,
|
||||
uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
getFilesForGallery = (props) => {
|
||||
const manifest = this.attachmentManifest(props.files);
|
||||
const files = manifest.imageAttachments.concat(manifest.nonImageAttachments);
|
||||
const results = [];
|
||||
|
||||
if (files && files.length) {
|
||||
files.forEach((file) => {
|
||||
results.push(file);
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
handlePreviewPress = preventDoubleTap((idx) => {
|
||||
openGalleryAtIndex(idx, this.galleryFiles);
|
||||
});
|
||||
|
||||
isSingleImage = (files) => (files.length === 1 && isImage(files[0]));
|
||||
|
||||
renderItems = (items, moreImagesCount, includeGutter = false) => {
|
||||
const {canDownloadFiles, isReplyPost, onLongPress, theme} = this.props;
|
||||
const isSingleImage = this.isSingleImage(items);
|
||||
let nonVisibleImagesCount;
|
||||
let container = styles.container;
|
||||
const containerWithGutter = [container, styles.gutter];
|
||||
|
||||
return items.map((file, idx) => {
|
||||
if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) {
|
||||
nonVisibleImagesCount = moreImagesCount;
|
||||
}
|
||||
|
||||
if (idx !== 0 && includeGutter) {
|
||||
container = containerWithGutter;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={container}
|
||||
key={file.id}
|
||||
>
|
||||
<FileAttachment
|
||||
key={file.id}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file}
|
||||
id={file.id}
|
||||
index={this.attachmentIndex(file.id)}
|
||||
onPreviewPress={this.handlePreviewPress}
|
||||
onLongPress={onLongPress}
|
||||
theme={theme}
|
||||
isSingleImage={isSingleImage}
|
||||
nonVisibleImagesCount={nonVisibleImagesCount}
|
||||
wrapperWidth={getViewPortWidth(isReplyPost, this.hasPermanentSidebar())}
|
||||
inViewPort={this.state.inViewPort}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
renderImageRow = (images) => {
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {isReplyPost} = this.props;
|
||||
const visibleImages = images.slice(0, MAX_VISIBLE_ROW_IMAGES);
|
||||
const hasFixedSidebar = this.hasPermanentSidebar();
|
||||
const portraitPostWidth = getViewPortWidth(isReplyPost, hasFixedSidebar);
|
||||
|
||||
let nonVisibleImagesCount;
|
||||
if (images.length > MAX_VISIBLE_ROW_IMAGES) {
|
||||
nonVisibleImagesCount = images.length - MAX_VISIBLE_ROW_IMAGES;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.row, {width: portraitPostWidth}]}>
|
||||
{ this.renderItems(visibleImages, nonVisibleImagesCount, true) }
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {canDownloadFiles, fileIds, files, isFailed} = this.props;
|
||||
|
||||
if (!files.length && fileIds.length > 0) {
|
||||
return fileIds.map((id, idx) => (
|
||||
<FileAttachment
|
||||
key={id}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={{loading: true}}
|
||||
id={id}
|
||||
index={idx}
|
||||
theme={this.props.theme}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const manifest = this.attachmentManifest(files);
|
||||
|
||||
return (
|
||||
<View style={[isFailed && styles.failed]}>
|
||||
{this.renderImageRow(manifest.imageAttachments)}
|
||||
{this.renderItems(manifest.nonImageAttachments)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
marginTop: 5,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gutter: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
failed: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import FileAttachment from './file_attachment_list.js';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
jest.mock('react-native-file-viewer', () => ({
|
||||
open: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('FileAttachmentList', () => {
|
||||
const files = [{
|
||||
create_at: 1546893090093,
|
||||
delete_at: 0,
|
||||
extension: 'png',
|
||||
has_preview_image: true,
|
||||
height: 171,
|
||||
id: 'fileId',
|
||||
mime_type: 'image/png',
|
||||
name: 'image01.png',
|
||||
post_id: 'postId',
|
||||
size: 14894,
|
||||
update_at: 1546893090093,
|
||||
user_id: 'userId',
|
||||
width: 425,
|
||||
},
|
||||
{
|
||||
create_at: 1546893090093,
|
||||
delete_at: 0,
|
||||
extension: 'png',
|
||||
has_preview_image: true,
|
||||
height: 800,
|
||||
id: 'otherFileId',
|
||||
mime_type: 'image/png',
|
||||
name: 'image02.png',
|
||||
post_id: 'postId',
|
||||
size: 24894,
|
||||
update_at: 1546893090093,
|
||||
user_id: 'userId',
|
||||
width: 555,
|
||||
}];
|
||||
|
||||
const nonImage = {
|
||||
extension: 'other',
|
||||
id: 'fileId',
|
||||
mime_type: 'other/type',
|
||||
name: 'file01.other',
|
||||
post_id: 'postId',
|
||||
size: 14894,
|
||||
user_id: 'userId',
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
canDownloadFiles: true,
|
||||
deviceHeight: 680,
|
||||
deviceWidth: 660,
|
||||
fileIds: ['fileId'],
|
||||
files: [files[0]],
|
||||
postId: 'postId',
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
test('should match snapshot with a single image file', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with two image files', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
files,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with three image files', () => {
|
||||
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [...files, thirdImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with four image files', () => {
|
||||
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
|
||||
const fourthImage = {...files[1], id: 'fourthFileId', name: 'image04.png'};
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [...files, thirdImage, fourthImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with more than four image files', () => {
|
||||
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
|
||||
const fourthImage = {...files[1], id: 'fourthFileId', name: 'image04.png'};
|
||||
const fifthImage = {...files[1], id: 'fifthFileId', name: 'image05.png'};
|
||||
const sixthImage = {...files[1], id: 'sixthFileId', name: 'image06.png'};
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [...files, thirdImage, fourthImage, fifthImage, sixthImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with non-image attachment', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [nonImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot with combination of image and non-image attachments', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
files: [...files, nonImage],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should call getFilesForGallery on props change', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...props}/>,
|
||||
);
|
||||
|
||||
wrapper.instance().getFilesForGallery = jest.fn().mockImplementationOnce(() => []);
|
||||
wrapper.setProps({files: [files[0], files[1]]});
|
||||
expect(wrapper.instance().getFilesForGallery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment-timezone';
|
||||
import {Text} from 'react-native';
|
||||
|
||||
export default class FormattedDate extends React.PureComponent {
|
||||
static propTypes = {
|
||||
format: PropTypes.string,
|
||||
timeZone: PropTypes.string,
|
||||
value: PropTypes.any.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
format: 'ddd, MMM DD, YYYY',
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
format,
|
||||
timeZone,
|
||||
value,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
let formattedDate = moment(value).format(format);
|
||||
if (timeZone) {
|
||||
formattedDate = moment.tz(value, timeZone).format(format);
|
||||
}
|
||||
|
||||
return <Text {...props}>{formattedDate}</Text>;
|
||||
}
|
||||
}
|
||||
33
app/components/formatted_date.tsx
Normal file
33
app/components/formatted_date.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import React from 'react';
|
||||
import {Text, TextProps} from 'react-native';
|
||||
|
||||
import type {UserTimezone} from '@mm-redux/types/users';
|
||||
|
||||
type FormattedDateProps = TextProps & {
|
||||
format: string;
|
||||
timezone?: string | UserTimezone | null;
|
||||
value: number | string | Date;
|
||||
}
|
||||
|
||||
const FormattedDate = ({format, timezone, value, ...props}: FormattedDateProps) => {
|
||||
let formattedDate = moment(value).format(format);
|
||||
if (timezone) {
|
||||
let zone = timezone as string;
|
||||
if (typeof timezone === 'object') {
|
||||
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
|
||||
}
|
||||
formattedDate = moment.tz(value, zone).format(format);
|
||||
}
|
||||
|
||||
return <Text {...props}>{formattedDate}</Text>;
|
||||
};
|
||||
|
||||
FormattedDate.defaultProps = {
|
||||
format: 'ddd, MMM DD, YYYY',
|
||||
};
|
||||
|
||||
export default FormattedDate;
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {createElement, isValidElement} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Text} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
export default class FormattedText extends React.PureComponent {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
defaultMessage: PropTypes.string,
|
||||
values: PropTypes.object,
|
||||
testID: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
defaultMessage: '',
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
defaultMessage,
|
||||
values,
|
||||
...props
|
||||
} = this.props;
|
||||
const {formatMessage} = this.context.intl;
|
||||
|
||||
let tokenDelimiter;
|
||||
let tokenizedValues;
|
||||
let elements;
|
||||
const hasValues = values && Object.keys(values).length > 0;
|
||||
if (hasValues) {
|
||||
// Creates a token with a random UID that should not be guessable or
|
||||
// conflict with other parts of the `message` string.
|
||||
const uid = Math.floor(Math.random() * 0x10000000000).toString(16);
|
||||
|
||||
const generateToken = (() => {
|
||||
let counter = 0;
|
||||
return () => {
|
||||
const elementId = `ELEMENT-${uid}-${counter += 1}`;
|
||||
return elementId;
|
||||
};
|
||||
})();
|
||||
|
||||
// Splitting with a delimiter to support IE8. When using a regex
|
||||
// with a capture group IE8 does not include the capture group in
|
||||
// the resulting array.
|
||||
tokenDelimiter = `@__${uid}__@`;
|
||||
tokenizedValues = {};
|
||||
elements = {};
|
||||
|
||||
// Iterates over the `props` to keep track of any React Element
|
||||
// values so they can be represented by the `token` as a placeholder
|
||||
// when the `message` is formatted. This allows the formatted
|
||||
// message to then be broken-up into parts with references to the
|
||||
// React Elements inserted back in.
|
||||
Object.keys(values).forEach((name) => {
|
||||
const value = values[name];
|
||||
|
||||
if (isValidElement(value)) {
|
||||
const token = generateToken();
|
||||
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;
|
||||
elements[token] = value;
|
||||
} else {
|
||||
tokenizedValues[name] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const descriptor = {id, defaultMessage};
|
||||
const formattedMessage = formatMessage(descriptor, tokenizedValues || values);
|
||||
const hasElements = elements && Object.keys(elements).length > 0;
|
||||
|
||||
let nodes;
|
||||
if (hasElements) {
|
||||
// Split the message into parts so the React Element values captured
|
||||
// above can be inserted back into the rendered message. This
|
||||
// approach allows messages to render with React Elements while
|
||||
// keeping React's virtual diffing working properly.
|
||||
nodes = formattedMessage.
|
||||
split(tokenDelimiter).
|
||||
filter((part) => Boolean(part)).
|
||||
map((part) => elements[part] || part);
|
||||
} else {
|
||||
nodes = [formattedMessage];
|
||||
}
|
||||
|
||||
return createElement(Text, props, ...nodes);
|
||||
}
|
||||
}
|
||||
83
app/components/formatted_text.tsx
Normal file
83
app/components/formatted_text.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {createElement, isValidElement} from 'react';
|
||||
import {Text, TextProps} from 'react-native';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
|
||||
type FormattedTextProps = TextProps & {
|
||||
id: string;
|
||||
defaultMessage: string;
|
||||
intl: typeof intlShape;
|
||||
values?: Record<string, React.ReactElement<unknown, string | React.JSXElementConstructor<any>>>;
|
||||
}
|
||||
|
||||
const FormattedText = ({id, defaultMessage, intl, values, ...props}: FormattedTextProps) => {
|
||||
const {formatMessage} = intl;
|
||||
|
||||
let tokenDelimiter = '';
|
||||
const tokenizedValues: Record<string, string|number> = {};
|
||||
const elements: Record<string, React.ReactElement<unknown, string | React.JSXElementConstructor<any>>> = {};
|
||||
|
||||
if (values && Object.keys(values).length > 0) {
|
||||
// Creates a token with a random UID that should not be guessable or
|
||||
// conflict with other parts of the `message` string.
|
||||
const uid = Math.floor(Math.random() * 0x10000000000).toString(16);
|
||||
|
||||
const generateToken = (() => {
|
||||
let counter = 0;
|
||||
return () => {
|
||||
const elementId = `ELEMENT-${uid}-${counter += 1}`;
|
||||
return elementId;
|
||||
};
|
||||
})();
|
||||
|
||||
// Splitting with a delimiter to support IE8. When using a regex
|
||||
// with a capture group IE8 does not include the capture group in
|
||||
// the resulting array.
|
||||
tokenDelimiter = `@__${uid}__@`;
|
||||
|
||||
// Iterates over the `props` to keep track of any React Element
|
||||
// values so they can be represented by the `token` as a placeholder
|
||||
// when the `message` is formatted. This allows the formatted
|
||||
// message to then be broken-up into parts with references to the
|
||||
// React Elements inserted back in.
|
||||
Object.keys(values).forEach((name) => {
|
||||
const value = values[name];
|
||||
|
||||
if (isValidElement(value)) {
|
||||
const token = generateToken();
|
||||
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;
|
||||
elements[token] = value;
|
||||
} else {
|
||||
tokenizedValues[name] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const descriptor = {id, defaultMessage};
|
||||
const formattedMessage = formatMessage(descriptor, tokenizedValues || values);
|
||||
const hasElements = elements && Object.keys(elements).length > 0;
|
||||
|
||||
let nodes;
|
||||
if (hasElements) {
|
||||
// Split the message into parts so the React Element values captured
|
||||
// above can be inserted back into the rendered message. This
|
||||
// approach allows messages to render with React Elements while
|
||||
// keeping React's virtual diffing working properly.
|
||||
nodes = formattedMessage.
|
||||
split(tokenDelimiter).
|
||||
filter((part: string) => Boolean(part)).
|
||||
map((part: string) => elements[part] || part);
|
||||
} else {
|
||||
nodes = [formattedMessage];
|
||||
}
|
||||
|
||||
return createElement(Text, props, ...nodes);
|
||||
};
|
||||
|
||||
FormattedText.defautProps = {
|
||||
defaultMessage: '',
|
||||
};
|
||||
|
||||
export default injectIntl(FormattedText);
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Text} from 'react-native';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
export default class FormattedTime extends React.PureComponent {
|
||||
static propTypes = {
|
||||
value: PropTypes.any.isRequired,
|
||||
timeZone: PropTypes.string,
|
||||
children: PropTypes.func,
|
||||
hour12: PropTypes.bool,
|
||||
style: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
testID: PropTypes.string,
|
||||
};
|
||||
|
||||
getFormattedTime = () => {
|
||||
const {
|
||||
value,
|
||||
timeZone,
|
||||
hour12,
|
||||
} = this.props;
|
||||
|
||||
let format = 'H:mm';
|
||||
if (hour12) {
|
||||
const localeFormat = moment.localeData().longDateFormat('LT');
|
||||
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
|
||||
}
|
||||
|
||||
if (timeZone) {
|
||||
return moment.tz(value, timeZone).format(format);
|
||||
}
|
||||
|
||||
return moment(value).format(format);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {children, style, testID} = this.props;
|
||||
const formattedTime = this.getFormattedTime();
|
||||
|
||||
if (typeof children === 'function') {
|
||||
return children(formattedTime);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={style}
|
||||
testID={testID}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ import FormattedTime from './formatted_time';
|
|||
describe('FormattedTime', () => {
|
||||
const baseProps = {
|
||||
value: 1548788533405,
|
||||
timeZone: 'UTC',
|
||||
hour12: true,
|
||||
timezone: 'UTC',
|
||||
isMilitaryTime: false,
|
||||
};
|
||||
|
||||
it('should render correctly', () => {
|
||||
|
|
@ -28,7 +28,7 @@ describe('FormattedTime', () => {
|
|||
const viewTwo = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
hour12={false}
|
||||
isMilitaryTime={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ describe('FormattedTime', () => {
|
|||
const koViewTwo = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
hour12={false}
|
||||
isMilitaryTime={true}
|
||||
/>,
|
||||
'ko',
|
||||
);
|
||||
|
|
@ -72,7 +72,7 @@ describe('FormattedTime', () => {
|
|||
const viewOne = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
timeZone='NZ-CHAT'
|
||||
timezone='NZ-CHAT'
|
||||
/>,
|
||||
'es',
|
||||
);
|
||||
|
|
@ -83,8 +83,8 @@ describe('FormattedTime', () => {
|
|||
const viewTwo = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
timeZone='NZ-CHAT'
|
||||
hour12={false}
|
||||
timezone='NZ-CHAT'
|
||||
isMilitaryTime={true}
|
||||
/>,
|
||||
'es',
|
||||
);
|
||||
|
|
|
|||
41
app/components/formatted_time.tsx
Normal file
41
app/components/formatted_time.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import React from 'react';
|
||||
import {Text, TextProps} from 'react-native';
|
||||
|
||||
import type {UserTimezone} from '@mm-redux/types/users';
|
||||
|
||||
type FormattedTimeProps = TextProps & {
|
||||
isMilitaryTime: boolean;
|
||||
timezone: UserTimezone | string;
|
||||
value: number | string | Date;
|
||||
}
|
||||
|
||||
const FormattedTime = ({isMilitaryTime, timezone, value, ...props}: FormattedTimeProps) => {
|
||||
const getFormattedTime = () => {
|
||||
let format = 'H:mm';
|
||||
if (!isMilitaryTime) {
|
||||
const localeFormat = moment.localeData().longDateFormat('LT');
|
||||
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
|
||||
}
|
||||
|
||||
let zone = timezone as string;
|
||||
if (typeof timezone === 'object') {
|
||||
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
|
||||
}
|
||||
|
||||
return timezone ? moment.tz(value, zone).format(format) : moment(value).format(format);
|
||||
};
|
||||
|
||||
const formattedTime = getFormattedTime();
|
||||
|
||||
return (
|
||||
<Text {...props}>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormattedTime;
|
||||
|
|
@ -10,9 +10,12 @@ import EventEmitter from '@mm-redux/utils/event_emitter';
|
|||
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
|
||||
// TODO: Use permanentSidebar and splitView hooks instead
|
||||
export default class ImageViewPort extends PureComponent {
|
||||
mounted = false;
|
||||
state = {
|
||||
inViewPort: false,
|
||||
isSplitView: false,
|
||||
permanentSidebar: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
|
|
@ -32,7 +35,7 @@ export default class ImageViewPort extends PureComponent {
|
|||
handleDimensions = () => {
|
||||
if (this.mounted) {
|
||||
if (DeviceTypes.IS_TABLET) {
|
||||
mattermostManaged.isRunningInSplitView().then((result) => {
|
||||
mattermostManaged.isRunningInSplitView().then((result: any) => {
|
||||
const isSplitView = Boolean(result.isSplitView);
|
||||
this.setState({isSplitView});
|
||||
});
|
||||
|
|
@ -2,18 +2,16 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import InteractiveDialogController from './interactive_dialog_controller';
|
||||
|
||||
jest.mock('react-intl');
|
||||
|
||||
describe('InteractiveDialogController', () => {
|
||||
test('should open interactive dialog as alert or screen depending on with or without element', () => {
|
||||
let baseProps = getBaseProps('trigger_id_1');
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<InteractiveDialogController {...baseProps}/>,
|
||||
{context: {intl: {formatMessage: jest.fn()}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
ViewPropTypes,
|
||||
} from 'react-native';
|
||||
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
|
|
|||
|
|
@ -11,26 +11,16 @@ export default class Hashtag extends React.PureComponent {
|
|||
static propTypes = {
|
||||
hashtag: PropTypes.string.isRequired,
|
||||
linkStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
onHashtagPress: PropTypes.func,
|
||||
};
|
||||
|
||||
handlePress = async () => {
|
||||
const {
|
||||
onHashtagPress,
|
||||
hashtag,
|
||||
} = this.props;
|
||||
|
||||
if (onHashtagPress) {
|
||||
onHashtagPress(hashtag);
|
||||
|
||||
return;
|
||||
}
|
||||
const {hashtag} = this.props;
|
||||
|
||||
// Close thread view, permalink view, etc
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
|
||||
showSearchModal('#' + this.props.hashtag);
|
||||
showSearchModal('#' + hashtag);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -37,25 +37,4 @@ describe('Hashtag', () => {
|
|||
expect(popToRoot).toHaveBeenCalled();
|
||||
expect(showSearchModal).toHaveBeenCalledWith('#test');
|
||||
});
|
||||
|
||||
test('handlePress should call onHashtagPress if provided', async () => {
|
||||
const dismissAllModals = jest.spyOn(NavigationActions, 'dismissAllModals');
|
||||
const popToRoot = jest.spyOn(NavigationActions, 'popToRoot');
|
||||
const showSearchModal = jest.spyOn(NavigationActions, 'showSearchModal');
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHashtagPress: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(<Hashtag {...props}/>);
|
||||
|
||||
await wrapper.instance().handlePress();
|
||||
|
||||
expect(dismissAllModals).not.toBeCalled();
|
||||
expect(popToRoot).not.toBeCalled();
|
||||
expect(showSearchModal).not.toBeCalled();
|
||||
|
||||
expect(props.onHashtagPress).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,13 +49,11 @@ export default class Markdown extends PureComponent {
|
|||
mentionKeys: PropTypes.array,
|
||||
minimumHashtagLength: PropTypes.number,
|
||||
onChannelLinkPress: PropTypes.func,
|
||||
onHashtagPress: PropTypes.func,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
onPostPress: PropTypes.func,
|
||||
postId: PropTypes.string,
|
||||
textStyles: PropTypes.object,
|
||||
theme: PropTypes.object,
|
||||
value: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
disableHashtags: PropTypes.bool,
|
||||
disableAtMentions: PropTypes.bool,
|
||||
disableChannelLink: PropTypes.bool,
|
||||
|
|
@ -274,7 +272,6 @@ export default class Markdown extends PureComponent {
|
|||
<Hashtag
|
||||
hashtag={hashtag}
|
||||
linkStyle={this.props.textStyles.link}
|
||||
onHashtagPress={this.props.onHashtagPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -417,10 +414,7 @@ export default class Markdown extends PureComponent {
|
|||
|
||||
renderLink = ({children, href}) => {
|
||||
return (
|
||||
<MarkdownLink
|
||||
href={href}
|
||||
onPermalinkPress={this.props.onPermalinkPress}
|
||||
>
|
||||
<MarkdownLink href={href}>
|
||||
{children}
|
||||
</MarkdownLink>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {PropTypes} from 'prop-types';
|
||||
import React from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {Keyboard, StyleSheet, Text, View} from 'react-native';
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import {PropTypes} from 'prop-types';
|
||||
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import BottomSheet from 'app/utils/bottom_sheet';
|
||||
import {getDisplayNameForLanguage} from 'app/utils/markdown';
|
||||
import {preventDoubleTap} from 'app/utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
import {goToScreen} from 'app/actions/navigation';
|
||||
import {goToScreen} from '@actions/navigation';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import mattermostManaged from '@mattermost-managed';
|
||||
import BottomSheet from '@utils/bottom_sheet';
|
||||
import {getDisplayNameForLanguage} from '@utils/markdown';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
const MAX_LINES = 4;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default class MarkdownEmoji extends PureComponent {
|
|||
static propTypes = {
|
||||
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
isEdited: PropTypes.bool,
|
||||
shouldRenderJumboEmoji: PropTypes.bool.isRequired,
|
||||
isJumboEmoji: PropTypes.bool.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
||||
|
|
@ -44,7 +44,7 @@ export default class MarkdownEmoji extends PureComponent {
|
|||
};
|
||||
|
||||
computeTextStyle = (baseStyle) => {
|
||||
if (!this.props.shouldRenderJumboEmoji) {
|
||||
if (!this.props.isJumboEmoji) {
|
||||
return baseStyle;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ describe('MarkdownEmoji', () => {
|
|||
const baseProps = {
|
||||
baseTextStyle: {color: '#3d3c40', fontSize: 15, lineHeight: 20},
|
||||
isEdited: false,
|
||||
shouldRenderJumboEmoji: true,
|
||||
isJumboEmoji: true,
|
||||
theme: Preferences.THEMES.default,
|
||||
value: ':smile:',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ import TouchableWithFeedback from '@components/touchable_with_feedback';
|
|||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import BottomSheet from '@utils/bottom_sheet';
|
||||
import {generateId} from '@utils/file';
|
||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge, openGalleryAtIndex} from '@utils/images';
|
||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {normalizeProtocol, tryOpenURL} from '@utils/url';
|
||||
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {handleSelectChannelByName} from '@actions/views/channel';
|
||||
import {showPermalink} from '@actions/views/permalink';
|
||||
import {getConfig, getCurrentUrl} from '@mm-redux/selectors/entities/general';
|
||||
import {getCurrentTeam} from '@mm-redux/selectors/entities/teams';
|
||||
import {handleSelectChannelByName} from 'app/actions/views/channel';
|
||||
|
||||
import MarkdownLink from './markdown_link';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ function mapDispatchToProps(dispatch) {
|
|||
return {
|
||||
actions: bindActionCreators({
|
||||
handleSelectChannelByName,
|
||||
showPermalink,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,17 +22,16 @@ export default class MarkdownLink extends PureComponent {
|
|||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
handleSelectChannelByName: PropTypes.func.isRequired,
|
||||
showPermalink: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]),
|
||||
href: PropTypes.string.isRequired,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
serverURL: PropTypes.string,
|
||||
siteURL: PropTypes.string.isRequired,
|
||||
currentTeamName: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onPermalinkPress: () => true,
|
||||
serverURL: '',
|
||||
siteURL: '',
|
||||
};
|
||||
|
|
@ -42,7 +41,9 @@ export default class MarkdownLink extends PureComponent {
|
|||
};
|
||||
|
||||
handlePress = preventDoubleTap(async () => {
|
||||
const {href, onPermalinkPress, serverURL, siteURL} = this.props;
|
||||
const {intl} = this.context;
|
||||
const {actions, currentTeamName, href, serverURL, siteURL} = this.props;
|
||||
const {handleSelectChannelByName, showPermalink} = actions;
|
||||
const url = normalizeProtocol(href);
|
||||
|
||||
if (!url) {
|
||||
|
|
@ -58,14 +59,10 @@ export default class MarkdownLink extends PureComponent {
|
|||
|
||||
if (match) {
|
||||
if (match.type === DeepLinkTypes.CHANNEL) {
|
||||
const {intl} = this.context;
|
||||
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel.bind(null, intl), intl);
|
||||
handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel, intl);
|
||||
} else if (match.type === DeepLinkTypes.PERMALINK) {
|
||||
if (match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
|
||||
onPermalinkPress(match.postId, this.props.currentTeamName);
|
||||
} else {
|
||||
onPermalinkPress(match.postId, match.teamName);
|
||||
}
|
||||
const teamName = match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT ? currentTeamName : match.teamName;
|
||||
showPermalink(intl, teamName, match.postId);
|
||||
}
|
||||
} else {
|
||||
const onError = () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import CompassIcon from '@components/compass_icon';
|
|||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import {calculateDimensions, isGifTooLarge, openGalleryAtIndex} from '@utils/images';
|
||||
import {calculateDimensions, isGifTooLarge} from '@utils/images';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/file';
|
||||
|
||||
import type {PostImage} from '@mm-redux/types/posts';
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import Button from 'react-native-button';
|
||||
|
||||
import ActionButtonText from './action_button_text';
|
||||
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {getStatusColors} from '@utils/message_attachment_colors';
|
||||
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {ActionResult} from '@mm-redux/types/actions';
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<ActionResult>;
|
||||
};
|
||||
id: string;
|
||||
name: string;
|
||||
postId: string;
|
||||
theme: Theme,
|
||||
cookie?: string,
|
||||
disabled?: boolean,
|
||||
buttonColor?: string,
|
||||
}
|
||||
export default class ActionButton extends PureComponent<Props> {
|
||||
handleActionPress = preventDoubleTap(() => {
|
||||
const {actions, id, postId, cookie} = this.props;
|
||||
actions.doPostActionWithCookie(postId, id, cookie || '');
|
||||
}, 4000);
|
||||
|
||||
render() {
|
||||
const {name, theme, disabled, buttonColor} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
let customButtonStyle;
|
||||
let customButtonTextStyle;
|
||||
|
||||
if (buttonColor) {
|
||||
const STATUS_COLORS = getStatusColors(theme);
|
||||
const hexColor = STATUS_COLORS[buttonColor] || theme[buttonColor] || buttonColor;
|
||||
customButtonStyle = {borderColor: changeOpacity(hexColor, 0.25), backgroundColor: '#ffffff'};
|
||||
customButtonTextStyle = {color: hexColor};
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
containerStyle={[style.button, customButtonStyle]}
|
||||
disabledContainerStyle={style.buttonDisabled}
|
||||
onPress={this.handleActionPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ActionButtonText
|
||||
message={name}
|
||||
style={{...style.text, ...customButtonTextStyle}}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
const STATUS_COLORS = getStatusColors(theme);
|
||||
return {
|
||||
button: {
|
||||
borderRadius: 4,
|
||||
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
|
||||
borderWidth: 2,
|
||||
opacity: 1,
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
justifyContent: 'center',
|
||||
height: 36,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
|
||||
},
|
||||
text: {
|
||||
color: STATUS_COLORS.default,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
lineHeight: 17,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import ActionButton from './action_button';
|
||||
|
||||
import {doPostActionWithCookie} from '@mm-redux/actions/posts';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {ActionFunc, ActionResult} from '@mm-redux/types/actions';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
return {
|
||||
theme: getTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string | undefined) => Promise<ActionResult>;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
|
||||
doPostActionWithCookie,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ActionButton);
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AttachmentImage it matches snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"marginTop": 5,
|
||||
},
|
||||
Object {
|
||||
"width": 38,
|
||||
},
|
||||
]
|
||||
}
|
||||
type="none"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.1)",
|
||||
"borderRadius": 2,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
},
|
||||
Object {
|
||||
"height": 28,
|
||||
"width": 28,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Connect(ProgressiveImage)
|
||||
id="123"
|
||||
imageStyle={
|
||||
Object {
|
||||
"marginBottom": 5,
|
||||
"marginLeft": 2.5,
|
||||
"marginRight": 5,
|
||||
"marginTop": 2.5,
|
||||
}
|
||||
}
|
||||
imageUri="https://images.com/image.png"
|
||||
resizeMode="contain"
|
||||
style={
|
||||
Object {
|
||||
"height": 28,
|
||||
"width": 28,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
import AttachmentImage, {State, Props} from './index';
|
||||
|
||||
describe('AttachmentImage', () => {
|
||||
const baseProps = {
|
||||
deviceHeight: 256,
|
||||
deviceWidth: 128,
|
||||
imageMetadata: {width: 32, height: 32},
|
||||
imageUrl: 'https://images.com/image.png',
|
||||
theme: Preferences.THEMES.default,
|
||||
} as Props;
|
||||
|
||||
test('it matches snapshot', () => {
|
||||
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it sets state based on props', () => {
|
||||
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://images.com/image.png');
|
||||
expect(state.originalWidth).toBe(32);
|
||||
});
|
||||
|
||||
test('it does not render image if no imageUrl is provided', () => {
|
||||
const props = {...baseProps};
|
||||
delete props.imageUrl;
|
||||
delete props.imageMetadata;
|
||||
|
||||
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...props}/>);
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(false);
|
||||
expect(state.imageUri).toBe(null);
|
||||
});
|
||||
|
||||
test('it updates image when imageUrl prop changes', () => {
|
||||
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
wrapper.setProps({
|
||||
imageUrl: 'https://someothersite.com/picture.png',
|
||||
imageMetadata: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
},
|
||||
});
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://someothersite.com/picture.png');
|
||||
expect(state.originalWidth).toBe(96);
|
||||
});
|
||||
|
||||
test('it does not update image when an unrelated prop changes', () => {
|
||||
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
wrapper.setProps({
|
||||
theme: {...Preferences.THEMES.default},
|
||||
});
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://images.com/image.png');
|
||||
expect(state.originalWidth).toBe(32);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {generateId} from '@utils/file';
|
||||
import {isGifTooLarge, openGalleryAtIndex, calculateDimensions} from '@utils/images';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {PostImage} from '@mm-redux/types/posts';
|
||||
import {FileInfo} from '@mm-redux/types/files';
|
||||
|
||||
const VIEWPORT_IMAGE_OFFSET = 100;
|
||||
const VIEWPORT_IMAGE_CONTAINER_OFFSET = 10;
|
||||
|
||||
export type Props = {
|
||||
deviceHeight: number;
|
||||
deviceWidth: number;
|
||||
imageMetadata?: PostImage;
|
||||
imageUrl?: string;
|
||||
postId?: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export type State = {
|
||||
hasImage: boolean;
|
||||
imageUri: string | null;
|
||||
originalHeight?: number;
|
||||
originalWidth?: number;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export default class AttachmentImage extends PureComponent<Props, State> {
|
||||
private fileId: string;
|
||||
private mounted = false;
|
||||
private maxImageWidth = 0;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.fileId = generateId();
|
||||
this.state = {
|
||||
hasImage: Boolean(props.imageUrl),
|
||||
imageUri: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
const {imageUrl, imageMetadata} = this.props;
|
||||
|
||||
this.setViewPortMaxWidth();
|
||||
if (imageMetadata) {
|
||||
this.setImageDimensionsFromMeta(null, imageMetadata);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
this.setImageUrl(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.imageUrl && (prevProps.imageUrl !== this.props.imageUrl)) {
|
||||
this.setImageUrl(this.props.imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
getFileInfo = () => {
|
||||
const {imageUrl, postId} = this.props;
|
||||
const {
|
||||
imageUri: uri,
|
||||
originalHeight,
|
||||
originalWidth,
|
||||
} = this.state;
|
||||
|
||||
if (!imageUrl) {
|
||||
return {
|
||||
id: this.fileId,
|
||||
post_id: postId,
|
||||
uri,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
} as FileInfo;
|
||||
}
|
||||
|
||||
let filename = imageUrl.substring(imageUrl.lastIndexOf('/') + 1, imageUrl.indexOf('?') === -1 ? imageUrl.length : imageUrl.indexOf('?'));
|
||||
const extension = filename.split('.').pop();
|
||||
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
}
|
||||
|
||||
const out = {
|
||||
id: this.fileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: postId,
|
||||
uri,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
} as FileInfo;
|
||||
return out;
|
||||
}
|
||||
|
||||
handlePreviewImage = () => {
|
||||
const files = [this.getFileInfo()];
|
||||
openGalleryAtIndex(0, files);
|
||||
};
|
||||
|
||||
setImageDimensions = (imageUri: string | null, dimensions: {width?: number; height?: number;}, originalWidth: number, originalHeight: number) => {
|
||||
if (this.mounted) {
|
||||
this.setState({
|
||||
...dimensions,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
imageUri,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setImageDimensionsFromMeta = (imageUri: string | null, imageMetadata: PostImage) => {
|
||||
const dimensions = calculateDimensions(imageMetadata.height, imageMetadata.width, this.maxImageWidth);
|
||||
this.setImageDimensions(imageUri, dimensions, imageMetadata.width, imageMetadata.height);
|
||||
};
|
||||
|
||||
setImageUrl = (imageURL: string) => {
|
||||
const {imageMetadata} = this.props;
|
||||
|
||||
if (imageMetadata) {
|
||||
this.setImageDimensionsFromMeta(imageURL, imageMetadata);
|
||||
}
|
||||
};
|
||||
|
||||
setViewPortMaxWidth = () => {
|
||||
const {deviceWidth, deviceHeight} = this.props;
|
||||
const viewPortWidth = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
|
||||
this.maxImageWidth = viewPortWidth - VIEWPORT_IMAGE_OFFSET;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {imageMetadata, theme} = this.props;
|
||||
const {hasImage, height, imageUri, width} = this.state;
|
||||
|
||||
if (!hasImage || isGifTooLarge(imageMetadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
let progressiveImage;
|
||||
if (imageUri) {
|
||||
progressiveImage = (
|
||||
<ProgressiveImage
|
||||
id={this.fileId}
|
||||
imageStyle={style.attachmentMargin}
|
||||
style={{height, width}}
|
||||
imageUri={imageUri}
|
||||
resizeMode='contain'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
progressiveImage = (<View style={{width, height}}/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePreviewImage}
|
||||
style={[style.container, {width: this.maxImageWidth + VIEWPORT_IMAGE_CONTAINER_OFFSET}]}
|
||||
type={'none'}
|
||||
>
|
||||
<View
|
||||
style={[style.imageContainer, {width, height}]}
|
||||
>
|
||||
{progressiveImage}
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginTop: 5,
|
||||
},
|
||||
imageContainer: {
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
|
||||
borderWidth: 1,
|
||||
borderRadius: 2,
|
||||
flex: 1,
|
||||
},
|
||||
attachmentMargin: {
|
||||
marginTop: 2.5,
|
||||
marginLeft: 2.5,
|
||||
marginBottom: 5,
|
||||
marginRight: 5,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {LayoutChangeEvent, ScrollView, StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import ShowMoreButton from '@components/show_more_button';
|
||||
|
||||
import {PostMetadata} from '@mm-redux/types/posts';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
const SHOW_MORE_HEIGHT = 60;
|
||||
|
||||
type Props = {
|
||||
baseTextStyle: StyleProp<TextStyle>,
|
||||
blockStyles?: StyleProp<ViewStyle>[],
|
||||
deviceHeight: number,
|
||||
hasThumbnail?: boolean,
|
||||
metadata?: PostMetadata,
|
||||
onPermalinkPress?: () => void,
|
||||
textStyles?: StyleProp<TextStyle>[],
|
||||
theme?: Theme,
|
||||
value?: string,
|
||||
}
|
||||
|
||||
type State = {
|
||||
collapsed: boolean;
|
||||
isLongText: boolean;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
function getMaxHeight(deviceHeight: number) {
|
||||
return Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
|
||||
}
|
||||
|
||||
export default class AttachmentText extends PureComponent<Props, State> {
|
||||
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
|
||||
const {deviceHeight} = nextProps;
|
||||
const maxHeight = getMaxHeight(deviceHeight);
|
||||
|
||||
if (maxHeight !== prevState.maxHeight) {
|
||||
return {
|
||||
maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const maxHeight = getMaxHeight(props.deviceHeight);
|
||||
this.state = {
|
||||
collapsed: true,
|
||||
isLongText: false,
|
||||
maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
handleLayout = (event: LayoutChangeEvent) => {
|
||||
const {height} = event.nativeEvent.layout;
|
||||
const {maxHeight} = this.state;
|
||||
|
||||
if (height >= maxHeight) {
|
||||
this.setState({
|
||||
isLongText: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapseState = () => {
|
||||
const {collapsed} = this.state;
|
||||
this.setState({collapsed: !collapsed});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
hasThumbnail,
|
||||
metadata,
|
||||
onPermalinkPress,
|
||||
textStyles,
|
||||
theme,
|
||||
value,
|
||||
} = this.props;
|
||||
const {collapsed, isLongText, maxHeight} = this.state;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={hasThumbnail && style.container}>
|
||||
<ScrollView
|
||||
style={{maxHeight: (collapsed ? maxHeight : undefined), overflow: 'hidden'}}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
onLayout={this.handleLayout}
|
||||
removeClippedSubviews={isLongText && collapsed}
|
||||
>
|
||||
<Markdown
|
||||
|
||||
// TODO: remove any when migrating Markdown to typescript
|
||||
baseTextStyle={baseTextStyle as any}
|
||||
textStyles={textStyles}
|
||||
blockStyles={blockStyles}
|
||||
disableGallery={true}
|
||||
imagesMetadata={metadata?.images}
|
||||
value={value}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{isLongText &&
|
||||
<ShowMoreButton
|
||||
onPress={this.toggleCollapseState}
|
||||
showMore={collapsed}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
container: {
|
||||
paddingRight: 60,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {Alert, Text, View} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {tryOpenURL} from '@utils/url';
|
||||
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
type Props = {
|
||||
link?: string;
|
||||
theme: Theme;
|
||||
value?: string;
|
||||
}
|
||||
export default class AttachmentTitle extends PureComponent<Props> {
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
openLink = () => {
|
||||
const {link} = this.props;
|
||||
const {intl} = this.context;
|
||||
|
||||
if (link) {
|
||||
const onError = () => {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.text',
|
||||
defaultMessage: 'Unable to open the link.',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
tryOpenURL(link, onError);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
link,
|
||||
value,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
let title;
|
||||
if (link) {
|
||||
title = (
|
||||
<Text
|
||||
style={[style.title, Boolean(link) && style.link]}
|
||||
onPress={this.openLink}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
title = (
|
||||
<Markdown
|
||||
isEdited={false}
|
||||
isReplyPost={false}
|
||||
disableHashtags={true}
|
||||
disableAtMentions={true}
|
||||
disableChannelLink={true}
|
||||
disableGallery={true}
|
||||
autolinkedUrlSchemes={[]}
|
||||
mentionKeys={[]}
|
||||
theme={theme}
|
||||
value={value}
|
||||
baseTextStyle={style.title}
|
||||
textStyles={{link: style.link}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
{title}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginTop: 3,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
fontWeight: '600',
|
||||
marginBottom: 5,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
link: {
|
||||
color: theme.linkColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import {MessageAttachment as MessageAttachmentType} from '@mm-redux/types/message_attachments';
|
||||
import {PostMetadata} from '@mm-redux/types/posts';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
|
||||
import MessageAttachment from './message_attachment';
|
||||
|
||||
type Props = {
|
||||
attachments: MessageAttachmentType[],
|
||||
baseTextStyle?: StyleProp<TextStyle>,
|
||||
blockStyles?: StyleProp<ViewStyle>[],
|
||||
deviceHeight: number,
|
||||
deviceWidth: number,
|
||||
postId: string,
|
||||
metadata?: PostMetadata,
|
||||
onPermalinkPress?: () => void,
|
||||
theme: Theme,
|
||||
textStyles?: StyleProp<TextStyle>[],
|
||||
}
|
||||
|
||||
export default function MessageAttachments(props: Props) {
|
||||
const {
|
||||
attachments,
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
deviceHeight,
|
||||
deviceWidth,
|
||||
metadata,
|
||||
onPermalinkPress,
|
||||
postId,
|
||||
theme,
|
||||
textStyles,
|
||||
} = props;
|
||||
const content = [] as React.ReactNode[];
|
||||
|
||||
attachments.forEach((attachment, i) => {
|
||||
content.push(
|
||||
<MessageAttachment
|
||||
attachment={attachment}
|
||||
baseTextStyle={baseTextStyle}
|
||||
blockStyles={blockStyles}
|
||||
deviceHeight={deviceHeight}
|
||||
deviceWidth={deviceWidth}
|
||||
key={'att_' + i}
|
||||
metadata={metadata}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
postId={postId}
|
||||
theme={theme}
|
||||
textStyles={textStyles}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{flex: 1, flexDirection: 'column'}}>
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AttachmentFooter matches snapshot 1`] = `
|
||||
<ForwardRef(AnimatedComponentWrapper)
|
||||
pointerEvents="none"
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"height": 38,
|
||||
"position": "absolute",
|
||||
"width": "100%",
|
||||
"zIndex": 9,
|
||||
},
|
||||
Object {
|
||||
"backgroundColor": "rgba(147, 147, 147, 1)",
|
||||
"opacity": 0,
|
||||
"top": 50,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<ForwardRef(AnimatedComponentWrapper)
|
||||
edges={
|
||||
Array [
|
||||
"left",
|
||||
"right",
|
||||
]
|
||||
}
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"height": 38,
|
||||
"paddingLeft": 12,
|
||||
"paddingRight": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedText
|
||||
defaultMessage="Connected"
|
||||
id="mobile.offlineIndicator.connected"
|
||||
style={
|
||||
Object {
|
||||
"color": "#FFFFFF",
|
||||
"flex": 1,
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "flex-end",
|
||||
"height": 24,
|
||||
"justifyContent": "center",
|
||||
"paddingRight": 10,
|
||||
"width": 60,
|
||||
}
|
||||
}
|
||||
>
|
||||
<CompassIcon
|
||||
color="#FFFFFF"
|
||||
name="check"
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
</ForwardRef(AnimatedComponentWrapper)>
|
||||
</ForwardRef(AnimatedComponentWrapper)>
|
||||
`;
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {stopPeriodicStatusUpdates, startPeriodicStatusUpdates} from '@mm-redux/actions/users';
|
||||
import {init as initWebSocket, close as closeWebSocket} from '@actions/websocket';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
|
||||
import {connection} from 'app/actions/device';
|
||||
import {markChannelViewedAndReadOnReconnect, setChannelRetryFailed} from 'app/actions/views/channel';
|
||||
import {setCurrentUserStatusOffline, logout} from 'app/actions/views/user';
|
||||
import {getConnection, isLandscape} from 'app/selectors/device';
|
||||
|
||||
import NetworkIndicator from './network_indicator';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const {websocket} = state.requests.general;
|
||||
const websocketStatus = websocket.status;
|
||||
|
||||
return {
|
||||
currentChannelId: getCurrentChannelId(state),
|
||||
isLandscape: isLandscape(state),
|
||||
isOnline: getConnection(state),
|
||||
websocketErrorCount: websocket.error,
|
||||
websocketStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
closeWebSocket,
|
||||
connection,
|
||||
initWebSocket,
|
||||
logout,
|
||||
markChannelViewedAndReadOnReconnect,
|
||||
setChannelRetryFailed,
|
||||
setCurrentUserStatusOffline,
|
||||
startPeriodicStatusUpdates,
|
||||
stopPeriodicStatusUpdates,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(NetworkIndicator);
|
||||
36
app/components/network_indicator/index.ts
Normal file
36
app/components/network_indicator/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {stopPeriodicStatusUpdates, startPeriodicStatusUpdates} from '@mm-redux/actions/users';
|
||||
import {init as initWebSocket, close as closeWebSocket} from '@actions/websocket';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
|
||||
import {markChannelViewedAndReadOnReconnect} from 'app/actions/views/channel';
|
||||
import {setCurrentUserStatusOffline} from 'app/actions/views/user';
|
||||
|
||||
import type {GlobalState} from '@mm-redux/types/store';
|
||||
|
||||
import NetworkIndicator from './network';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
const {websocket} = state.requests.general;
|
||||
|
||||
return {
|
||||
channelId: getCurrentChannelId(state),
|
||||
errorCount: websocket.error,
|
||||
status: websocket.status,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
closeWebSocket,
|
||||
initWebSocket,
|
||||
markChannelViewedAndReadOnReconnect,
|
||||
setCurrentUserStatusOffline,
|
||||
startPeriodicStatusUpdates,
|
||||
stopPeriodicStatusUpdates,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(NetworkIndicator);
|
||||
291
app/components/network_indicator/network.tsx
Normal file
291
app/components/network_indicator/network.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// 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: () => 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) {
|
||||
firstRun.current = false;
|
||||
handleWebSocket(true);
|
||||
} else {
|
||||
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();
|
||||
} else {
|
||||
closeWebSocket(true);
|
||||
stopPeriodicStatusUpdates();
|
||||
setCurrentUserStatusOffline();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const networkListener = networkConnectionListener(handleConnectionChange);
|
||||
return () => networkListener.removeEventListener();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
handleWebSocket(false);
|
||||
if (retryTimeout.current) {
|
||||
clearTimeout(retryTimeout.current);
|
||||
retryTimeout.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = stateChange(async (appState: AppStateStatus) => {
|
||||
const active = appState === 'active';
|
||||
|
||||
handleWebSocket(active);
|
||||
|
||||
if (active) {
|
||||
// Clear the notifications for the current channel after one second
|
||||
// this is done so we can cancel it in case the app is brought to the
|
||||
// foreground by tapping a notification from another channel
|
||||
clearNotificationTimeout.current = setTimeout(clearNotifications, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
AppState.addEventListener('change', handleAppStateChange);
|
||||
|
||||
return () => {
|
||||
AppState.removeEventListener('change', handleAppStateChange);
|
||||
};
|
||||
}, [netinfo.isInternetReachable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (clearNotificationTimeout.current) {
|
||||
clearTimeout(clearNotificationTimeout.current);
|
||||
clearNotificationTimeout.current = undefined;
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== RequestStatus.SUCCESS && errorCount! >= 2) {
|
||||
setConnected(false);
|
||||
} else if (status === RequestStatus.SUCCESS) {
|
||||
setConnected(true);
|
||||
}
|
||||
}, [status, errorCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCount! > MAX_WEBSOCKET_RETRIES) {
|
||||
handleWebSocket(false);
|
||||
handleReconnect();
|
||||
}
|
||||
}, [errorCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const navbarChanged = (height: number) => {
|
||||
setTranslateY(height);
|
||||
};
|
||||
|
||||
EventEmitter.on(ViewTypes.CHANNEL_NAV_BAR_CHANGED, navbarChanged);
|
||||
return () => EventEmitter.off(ViewTypes.CHANNEL_NAV_BAR_CHANGED, navbarChanged);
|
||||
}, [translateY]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const onAnimation = (isFinished: boolean) => {
|
||||
if (isFinished) {
|
||||
runOnJS(hanleAnimationFinished)();
|
||||
}
|
||||
};
|
||||
|
||||
bgColor.value = withTiming(connected ? 1 : 0, {duration: 100, easing: Easing.linear});
|
||||
return {
|
||||
backgroundColor: interpolateColor(
|
||||
bgColor.value,
|
||||
[0, 1],
|
||||
colors,
|
||||
),
|
||||
transform: [{translateY: withTiming(connected ? 0 : translateY, {duration: 300}, onAnimation)}],
|
||||
};
|
||||
}, [connected, translateY]);
|
||||
|
||||
if (netinfo.isInternetReachable) {
|
||||
if (connected) {
|
||||
i18nId = t('mobile.offlineIndicator.connected');
|
||||
defaultMessage = 'Connected';
|
||||
action = (
|
||||
<View style={styles.actionContainer}>
|
||||
<CompassIcon
|
||||
color='#FFFFFF'
|
||||
name='check'
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
i18nId = t('mobile.offlineIndicator.connecting');
|
||||
defaultMessage = 'Connecting...';
|
||||
action = (
|
||||
<View style={styles.actionContainer}>
|
||||
<ActivityIndicator
|
||||
color='#FFFFFF'
|
||||
size='small'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
i18nId = t('mobile.offlineIndicator.offline');
|
||||
defaultMessage = 'No internet connection';
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents='none'
|
||||
style={[styles.container, animatedStyle]}
|
||||
>
|
||||
<SafeAreaView
|
||||
edges={['left', 'right']}
|
||||
style={styles.wrapper}
|
||||
>
|
||||
{Boolean(i18nId) &&
|
||||
<FormattedText
|
||||
defaultMessage={defaultMessage}
|
||||
id={i18nId}
|
||||
style={styles.message}
|
||||
testID='network_indicator.message'
|
||||
/>
|
||||
}
|
||||
{action}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkIndicator;
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
AppState,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {ViewTypes} from '@constants';
|
||||
import {INDICATOR_BAR_HEIGHT} from '@constants/view';
|
||||
import PushNotifications from '@init/push_notifications';
|
||||
import {debounce} from '@mm-redux/actions/helpers';
|
||||
import {RequestStatus} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {t} from '@utils/i18n';
|
||||
import networkConnectionListener, {checkConnection} from '@utils/network';
|
||||
|
||||
const MAX_WEBSOCKET_RETRIES = 3;
|
||||
const CONNECTION_RETRY_SECONDS = 5;
|
||||
const CONNECTION_RETRY_TIMEOUT = 1000 * CONNECTION_RETRY_SECONDS; // 30 seconds
|
||||
const {
|
||||
ANDROID_TOP_LANDSCAPE,
|
||||
ANDROID_TOP_PORTRAIT,
|
||||
IOS_TOP_LANDSCAPE,
|
||||
IOS_INSETS_TOP_PORTRAIT,
|
||||
} = ViewTypes;
|
||||
|
||||
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
|
||||
|
||||
export default class NetworkIndicator extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
closeWebSocket: PropTypes.func.isRequired,
|
||||
connection: PropTypes.func.isRequired,
|
||||
initWebSocket: PropTypes.func.isRequired,
|
||||
markChannelViewedAndReadOnReconnect: PropTypes.func.isRequired,
|
||||
logout: PropTypes.func.isRequired,
|
||||
setChannelRetryFailed: PropTypes.func.isRequired,
|
||||
setCurrentUserStatusOffline: PropTypes.func.isRequired,
|
||||
startPeriodicStatusUpdates: PropTypes.func.isRequired,
|
||||
stopPeriodicStatusUpdates: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
currentChannelId: PropTypes.string,
|
||||
isLandscape: PropTypes.bool,
|
||||
isOnline: PropTypes.bool,
|
||||
websocketErrorCount: PropTypes.number,
|
||||
websocketStatus: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
isOnline: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const navBarHeight = Platform.select({
|
||||
android: props.isLandscape ? ANDROID_TOP_LANDSCAPE : ANDROID_TOP_PORTRAIT,
|
||||
ios: props.isLandscape ? IOS_TOP_LANDSCAPE : IOS_INSETS_TOP_PORTRAIT,
|
||||
});
|
||||
|
||||
this.state = {
|
||||
opacity: 0,
|
||||
navBarHeight,
|
||||
};
|
||||
|
||||
this.top = new Animated.Value(navBarHeight - INDICATOR_BAR_HEIGHT);
|
||||
this.clearNotificationTimeout = null;
|
||||
|
||||
this.backgroundColor = new Animated.Value(0);
|
||||
this.firstRun = true;
|
||||
this.statusUpdates = false;
|
||||
|
||||
this.networkListener = networkConnectionListener(this.handleConnectionChange);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
|
||||
AppState.addEventListener('change', this.handleAppStateChange);
|
||||
EventEmitter.on(ViewTypes.CHANNEL_NAV_BAR_CHANGED, this.getNavBarHeight);
|
||||
|
||||
// Attempt to connect when this component mounts
|
||||
// if the websocket is already connected it does not try and connect again
|
||||
this.connect(true);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
currentChannelId: prevChannelId,
|
||||
websocketStatus: previousWebsocketStatus,
|
||||
} = prevProps;
|
||||
const {currentChannelId, websocketErrorCount, websocketStatus} = this.props;
|
||||
|
||||
if (currentChannelId !== prevChannelId && this.clearNotificationTimeout) {
|
||||
clearTimeout(this.clearNotificationTimeout);
|
||||
this.clearNotificationTimeout = null;
|
||||
}
|
||||
|
||||
if (prevState.navBarHeight !== this.state.navBarHeight) {
|
||||
const initialTop = websocketErrorCount || previousWebsocketStatus === RequestStatus.FAILURE || previousWebsocketStatus === RequestStatus.NOT_STARTED ? 0 : INDICATOR_BAR_HEIGHT;
|
||||
this.top.setValue(this.state.navBarHeight - initialTop);
|
||||
}
|
||||
|
||||
if (this.props.isOnline) {
|
||||
if (previousWebsocketStatus !== RequestStatus.SUCCESS && websocketStatus === RequestStatus.SUCCESS) {
|
||||
// Show the connected animation only if we had a previous network status
|
||||
this.connected();
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
} else if (previousWebsocketStatus === RequestStatus.STARTED && websocketStatus === RequestStatus.FAILURE && websocketErrorCount > MAX_WEBSOCKET_RETRIES) {
|
||||
this.handleWebSocket(false);
|
||||
this.handleReconnect();
|
||||
} else if (websocketStatus === RequestStatus.FAILURE) {
|
||||
this.show();
|
||||
}
|
||||
} else {
|
||||
this.offline();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const {closeWebSocket, stopPeriodicStatusUpdates} = this.props.actions;
|
||||
this.mounted = false;
|
||||
|
||||
closeWebSocket(false);
|
||||
stopPeriodicStatusUpdates();
|
||||
this.networkListener.removeEventListener();
|
||||
AppState.removeEventListener('change', this.handleAppStateChange);
|
||||
EventEmitter.off(ViewTypes.CHANNEL_NAV_BAR_CHANGED, this.getNavBarHeight);
|
||||
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
this.connectionRetryTimeout = null;
|
||||
}
|
||||
|
||||
connect = (displayBar = false) => {
|
||||
const {connection, startPeriodicStatusUpdates} = this.props.actions;
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
|
||||
NetInfo.fetch().then(async ({isConnected}) => {
|
||||
const {hasInternet, serverReachable} = await checkConnection(isConnected);
|
||||
|
||||
connection(hasInternet);
|
||||
this.hasInternet = hasInternet;
|
||||
this.serverReachable = serverReachable;
|
||||
|
||||
if (serverReachable) {
|
||||
this.statusUpdates = true;
|
||||
this.initializeWebSocket();
|
||||
startPeriodicStatusUpdates();
|
||||
} else {
|
||||
if (displayBar) {
|
||||
this.show();
|
||||
}
|
||||
|
||||
this.handleWebSocket(false);
|
||||
|
||||
if (hasInternet) {
|
||||
// try to reconnect cause we have internet
|
||||
this.handleReconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
connected = () => {
|
||||
if (this.visible) {
|
||||
this.visible = false;
|
||||
Animated.sequence([
|
||||
Animated.timing(
|
||||
this.backgroundColor, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: false,
|
||||
},
|
||||
),
|
||||
Animated.timing(
|
||||
this.top, {
|
||||
toValue: (this.state.navBarHeight - INDICATOR_BAR_HEIGHT),
|
||||
duration: 300,
|
||||
delay: 500,
|
||||
useNativeDriver: false,
|
||||
},
|
||||
),
|
||||
]).start(() => {
|
||||
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, false);
|
||||
this.backgroundColor.setValue(0);
|
||||
this.setState({
|
||||
opacity: 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getNavBarHeight = (navBarHeight) => {
|
||||
this.setState({navBarHeight});
|
||||
};
|
||||
|
||||
handleWebSocket = (open) => {
|
||||
const {actions} = this.props;
|
||||
const {
|
||||
closeWebSocket,
|
||||
startPeriodicStatusUpdates,
|
||||
stopPeriodicStatusUpdates,
|
||||
} = actions;
|
||||
|
||||
if (open) {
|
||||
this.statusUpdates = true;
|
||||
this.initializeWebSocket();
|
||||
startPeriodicStatusUpdates();
|
||||
} else if (this.statusUpdates) {
|
||||
this.statusUpdates = false;
|
||||
closeWebSocket(true);
|
||||
stopPeriodicStatusUpdates();
|
||||
}
|
||||
};
|
||||
|
||||
handleAppStateChange = async (appState) => {
|
||||
this.onStateChange(appState);
|
||||
};
|
||||
|
||||
handleConnectionChange = ({hasInternet, serverReachable}) => {
|
||||
const {connection} = this.props.actions;
|
||||
|
||||
// On first run always initialize the WebSocket
|
||||
// if we have internet connection
|
||||
if (hasInternet && this.firstRun) {
|
||||
this.initializeWebSocket();
|
||||
this.firstRun = false;
|
||||
|
||||
// if the state of the internet connection was previously known to be false,
|
||||
// don't exit connection handler in order for application to register it has
|
||||
// reconnected to the internet
|
||||
if (this.hasInternet !== false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent for being called more than once.
|
||||
if (this.hasInternet !== hasInternet) {
|
||||
this.hasInternet = hasInternet;
|
||||
connection(hasInternet);
|
||||
}
|
||||
|
||||
if (this.serverReachable !== serverReachable) {
|
||||
this.serverReachable = serverReachable;
|
||||
this.handleWebSocket(serverReachable);
|
||||
}
|
||||
};
|
||||
|
||||
handleReconnect = () => {
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
this.connectionRetryTimeout = setTimeout(() => {
|
||||
const {websocketStatus} = this.props;
|
||||
if (websocketStatus !== RequestStatus.STARTED || websocketStatus !== RequestStatus.SUCCESS) {
|
||||
this.connect();
|
||||
}
|
||||
}, CONNECTION_RETRY_TIMEOUT);
|
||||
};
|
||||
|
||||
initializeWebSocket = async () => {
|
||||
const {actions} = this.props;
|
||||
const {closeWebSocket, initWebSocket} = actions;
|
||||
|
||||
initWebSocket({forceConnection: true}).catch(() => {
|
||||
// we should dispatch a failure and show the app as disconnected
|
||||
closeWebSocket(true);
|
||||
});
|
||||
};
|
||||
|
||||
offline = () => {
|
||||
if (this.connectionRetryTimeout) {
|
||||
clearTimeout(this.connectionRetryTimeout);
|
||||
}
|
||||
|
||||
this.show();
|
||||
};
|
||||
|
||||
onStateChange = debounce((appState) => {
|
||||
const {actions, currentChannelId} = this.props;
|
||||
const active = appState === 'active';
|
||||
|
||||
if (active) {
|
||||
this.connect(true);
|
||||
|
||||
if (currentChannelId) {
|
||||
// Clear the notifications for the current channel after one second
|
||||
// this is done so we can cancel it in case the app is brought to the
|
||||
// foreground by tapping a notification from another channel
|
||||
this.clearNotificationTimeout = setTimeout(() => {
|
||||
PushNotifications.clearChannelNotifications(currentChannelId);
|
||||
actions.markChannelViewedAndReadOnReconnect(currentChannelId);
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
this.handleWebSocket(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
show = () => {
|
||||
if (!this.visible) {
|
||||
this.visible = true;
|
||||
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, true);
|
||||
this.setState({
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
Animated.timing(
|
||||
this.top, {
|
||||
toValue: this.state.navBarHeight,
|
||||
duration: 300,
|
||||
useNativeDriver: false,
|
||||
},
|
||||
).start(() => {
|
||||
this.props.actions.setCurrentUserStatusOffline();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {isOnline, websocketStatus} = this.props;
|
||||
const background = this.backgroundColor.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['#939393', '#629a41'],
|
||||
});
|
||||
|
||||
let i18nId;
|
||||
let defaultMessage;
|
||||
let action;
|
||||
|
||||
if (isOnline) {
|
||||
switch (websocketStatus) {
|
||||
case RequestStatus.NOT_STARTED:
|
||||
case RequestStatus.FAILURE:
|
||||
case RequestStatus.STARTED:
|
||||
i18nId = t('mobile.offlineIndicator.connecting');
|
||||
defaultMessage = 'Connecting...';
|
||||
action = (
|
||||
<View style={styles.actionContainer}>
|
||||
<ActivityIndicator
|
||||
color='#FFFFFF'
|
||||
size='small'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
break;
|
||||
case RequestStatus.SUCCESS:
|
||||
default:
|
||||
i18nId = t('mobile.offlineIndicator.connected');
|
||||
defaultMessage = 'Connected';
|
||||
action = (
|
||||
<View style={styles.actionContainer}>
|
||||
<CompassIcon
|
||||
color='#FFFFFF'
|
||||
name='check'
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
i18nId = t('mobile.offlineIndicator.offline');
|
||||
defaultMessage = 'No internet connection';
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents='none'
|
||||
style={[styles.container, {top: this.top, backgroundColor: background, opacity: this.state.opacity}]}
|
||||
>
|
||||
<AnimatedSafeAreaView
|
||||
edges={['left', 'right']}
|
||||
style={styles.wrapper}
|
||||
>
|
||||
<FormattedText
|
||||
defaultMessage={defaultMessage}
|
||||
id={i18nId}
|
||||
style={styles.message}
|
||||
/>
|
||||
{action}
|
||||
</AnimatedSafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: INDICATOR_BAR_HEIGHT,
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
...Platform.select({
|
||||
android: {
|
||||
elevation: 9,
|
||||
},
|
||||
ios: {
|
||||
zIndex: 9,
|
||||
},
|
||||
}),
|
||||
},
|
||||
wrapper: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
height: INDICATOR_BAR_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 12,
|
||||
paddingRight: 5,
|
||||
},
|
||||
message: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
actionContainer: {
|
||||
alignItems: 'flex-end',
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
paddingRight: 10,
|
||||
width: 60,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Animated} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import {ViewTypes} from '@constants';
|
||||
|
||||
import NetworkIndicator from './network_indicator';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('AttachmentFooter', () => {
|
||||
Animated.sequence = jest.fn(() => ({
|
||||
start: jest.fn((cb) => cb()),
|
||||
}));
|
||||
Animated.timing = jest.fn(() => ({
|
||||
start: jest.fn((cb) => cb()),
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
actions: {
|
||||
closeWebSocket: jest.fn(),
|
||||
connection: jest.fn(),
|
||||
initWebSocket: jest.fn(),
|
||||
markChannelViewedAndReadOnReconnect: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setChannelRetryFailed: jest.fn(),
|
||||
setCurrentUserStatusOffline: jest.fn(),
|
||||
startPeriodicStatusUpdates: jest.fn(),
|
||||
stopPeriodicStatusUpdates: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
it('matches snapshot', () => {
|
||||
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('show', () => {
|
||||
EventEmitter.emit = jest.fn();
|
||||
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
it('emits INDICATOR_BAR_VISIBLE with true only if not already visible', async () => {
|
||||
instance.visible = true;
|
||||
instance.show();
|
||||
expect(EventEmitter.emit).not.toHaveBeenCalled();
|
||||
|
||||
instance.visible = false;
|
||||
instance.show();
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, true);
|
||||
expect(instance.visible).toBe(true);
|
||||
expect(wrapper.state('opacity')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connected', () => {
|
||||
EventEmitter.emit = jest.fn();
|
||||
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
it('emits INDICATOR_BAR_VISIBLE with false only if visible', async () => {
|
||||
instance.visible = false;
|
||||
instance.connected();
|
||||
expect(EventEmitter.emit).not.toHaveBeenCalled();
|
||||
|
||||
instance.visible = true;
|
||||
instance.connected();
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, false);
|
||||
expect(instance.visible).toBe(false);
|
||||
expect(wrapper.state('opacity')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
|
||||
import {createPost, removePost} from '@mm-redux/actions/posts';
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
import {isChannelReadOnlyById} from '@mm-redux/selectors/entities/channels';
|
||||
import {getPost, makeGetCommentCountForPost, makeIsPostCommentMention} from '@mm-redux/selectors/entities/posts';
|
||||
import {getUser, getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {getMyPreferences, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {isDateLine, isStartOfNewMessages} from '@mm-redux/utils/post_list';
|
||||
import {isPostFlagged, isSystemMessage} from '@mm-redux/utils/post_utils';
|
||||
|
||||
import {insertToDraft, setPostTooltipVisible} from 'app/actions/views/channel';
|
||||
|
||||
import Post from './post';
|
||||
|
||||
function isConsecutivePost(post, previousPost) {
|
||||
let consecutivePost = false;
|
||||
|
||||
if (post && previousPost) {
|
||||
const postFromWebhook = Boolean(post?.props?.from_webhook); // eslint-disable-line camelcase
|
||||
const prevPostFromWebhook = Boolean(previousPost?.props?.from_webhook); // eslint-disable-line camelcase
|
||||
if (previousPost && previousPost.user_id === post.user_id &&
|
||||
post.create_at - previousPost.create_at <= Posts.POST_COLLAPSE_TIMEOUT &&
|
||||
!postFromWebhook && !prevPostFromWebhook &&
|
||||
!isSystemMessage(post) && !isSystemMessage(previousPost) &&
|
||||
(previousPost.root_id === post.root_id || previousPost.id === post.root_id)) {
|
||||
// The last post and this post were made by the same user within some time
|
||||
consecutivePost = true;
|
||||
}
|
||||
}
|
||||
return consecutivePost;
|
||||
}
|
||||
|
||||
function makeMapStateToProps() {
|
||||
const getCommentCountForPost = makeGetCommentCountForPost();
|
||||
const isPostCommentMention = makeIsPostCommentMention();
|
||||
return function mapStateToProps(state, ownProps) {
|
||||
const post = ownProps.post || getPost(state, ownProps.postId);
|
||||
const previousPostId = (isStartOfNewMessages(ownProps.previousPostId) || isDateLine(ownProps.previousPostId)) ? ownProps.beforePrevPostId : ownProps.previousPostId;
|
||||
const previousPost = getPost(state, previousPostId);
|
||||
const beforePrevPost = getPost(state, ownProps.beforePrevPostId);
|
||||
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const user = getUser(state, post.user_id);
|
||||
const isCommentMention = isPostCommentMention(state, post.id);
|
||||
let isFirstReply = true;
|
||||
let isLastReply = true;
|
||||
let commentedOnPost = null;
|
||||
|
||||
if (ownProps.renderReplies && post && post.root_id) {
|
||||
if (previousPostId) {
|
||||
if (previousPost && (previousPost.id === post.root_id || previousPost.root_id === post.root_id)) {
|
||||
// Previous post is root post or previous post is in same thread
|
||||
isFirstReply = false;
|
||||
} else {
|
||||
// Last post is not a comment on the same message
|
||||
commentedOnPost = getPost(state, post.root_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (ownProps.nextPostId) {
|
||||
const nextPost = getPost(state, ownProps.nextPostId);
|
||||
|
||||
if (nextPost && nextPost.root_id === post.root_id) {
|
||||
isLastReply = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelIsReadOnly: isChannelReadOnlyById(state, post.channel_id),
|
||||
currentUserId,
|
||||
post,
|
||||
isBot: (user ? user.is_bot : false),
|
||||
isFirstReply,
|
||||
isLastReply,
|
||||
consecutivePost: isConsecutivePost(post, previousPost),
|
||||
hasComments: getCommentCountForPost(state, {post}) > 0,
|
||||
commentedOnPost,
|
||||
theme: getTheme(state),
|
||||
isFlagged: isPostFlagged(post.id, myPreferences),
|
||||
isCommentMention,
|
||||
previousPostExists: Boolean(previousPost),
|
||||
beforePrevPostUserId: (beforePrevPost ? beforePrevPost.user_id : null),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
createPost,
|
||||
removePost,
|
||||
setPostTooltipVisible,
|
||||
insertToDraft,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(Post);
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
ViewPropTypes,
|
||||
} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from '@mm-redux/utils/post_utils';
|
||||
|
||||
import {showModalOverCurrentContext, showModal} from '@actions/navigation';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import PostBody from '@components/post_body';
|
||||
import PostHeader from '@components/post_header';
|
||||
import PostProfilePicture from '@components/post_profile_picture';
|
||||
import PostPreHeader from '@components/post_header/post_pre_header';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
|
||||
import {NavigationTypes} from '@constants';
|
||||
|
||||
import {t} from '@utils/i18n';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {fromAutoResponder} from '@utils/general';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
export default class Post extends PureComponent {
|
||||
static propTypes = {
|
||||
testID: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
createPost: PropTypes.func.isRequired,
|
||||
insertToDraft: PropTypes.func.isRequired,
|
||||
removePost: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
channelIsReadOnly: PropTypes.bool,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
highlight: PropTypes.bool,
|
||||
style: ViewPropTypes.style,
|
||||
post: PropTypes.object,
|
||||
renderReplies: PropTypes.bool,
|
||||
isFirstReply: PropTypes.bool,
|
||||
isLastReply: PropTypes.bool,
|
||||
isLastPost: PropTypes.bool,
|
||||
consecutivePost: PropTypes.bool,
|
||||
hasComments: PropTypes.bool,
|
||||
isSearchResult: PropTypes.bool,
|
||||
commentedOnPost: PropTypes.object,
|
||||
managedConfig: PropTypes.object,
|
||||
onHashtagPress: PropTypes.func,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
shouldRenderReplyButton: PropTypes.bool,
|
||||
showAddReaction: PropTypes.bool,
|
||||
showFullDate: PropTypes.bool,
|
||||
showLongPost: PropTypes.bool,
|
||||
theme: PropTypes.object.isRequired,
|
||||
onPress: PropTypes.func,
|
||||
onReply: PropTypes.func,
|
||||
isFlagged: PropTypes.bool,
|
||||
highlightPinnedOrFlagged: PropTypes.bool,
|
||||
skipFlaggedHeader: PropTypes.bool,
|
||||
skipPinnedHeader: PropTypes.bool,
|
||||
isCommentMention: PropTypes.bool,
|
||||
location: PropTypes.string,
|
||||
isBot: PropTypes.bool,
|
||||
previousPostExists: PropTypes.bool,
|
||||
beforePrevPostUserId: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
isSearchResult: false,
|
||||
showAddReaction: true,
|
||||
showLongPost: false,
|
||||
channelIsReadOnly: false,
|
||||
highlightPinnedOrFlagged: true,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.postBodyRef = React.createRef();
|
||||
}
|
||||
|
||||
goToUserProfile = async () => {
|
||||
const {intl} = this.context;
|
||||
const {post, theme} = this.props;
|
||||
const screen = 'UserProfile';
|
||||
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
|
||||
const passProps = {
|
||||
userId: post.user_id,
|
||||
};
|
||||
|
||||
if (!this.closeButton) {
|
||||
this.closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
|
||||
}
|
||||
|
||||
const options = {
|
||||
topBar: {
|
||||
leftButtons: [{
|
||||
id: 'close-settings',
|
||||
icon: this.closeButton,
|
||||
testID: 'close.settings.button',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
Keyboard.dismiss();
|
||||
showModal(screen, title, passProps, options);
|
||||
};
|
||||
|
||||
autofillUserMention = (username) => {
|
||||
this.props.actions.insertToDraft(`@${username} `);
|
||||
};
|
||||
|
||||
handleFailedPostPress = () => {
|
||||
const screen = 'OptionsModal';
|
||||
const passProps = {
|
||||
title: {
|
||||
id: t('mobile.post.failed_title'),
|
||||
defaultMessage: 'Unable to send your message:',
|
||||
},
|
||||
items: [{
|
||||
action: () => {
|
||||
const {failed, id, ...post} = this.props.post; // eslint-disable-line
|
||||
|
||||
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
|
||||
this.props.actions.createPost(post);
|
||||
},
|
||||
text: {
|
||||
id: t('mobile.post.failed_retry'),
|
||||
defaultMessage: 'Try Again',
|
||||
},
|
||||
}, {
|
||||
action: () => {
|
||||
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
|
||||
this.onRemovePost(this.props.post);
|
||||
},
|
||||
text: {
|
||||
id: t('mobile.post.failed_delete'),
|
||||
defaultMessage: 'Delete Message',
|
||||
},
|
||||
textStyle: {
|
||||
color: '#CC3239',
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
showModalOverCurrentContext(screen, passProps);
|
||||
};
|
||||
|
||||
handlePress = preventDoubleTap(() => {
|
||||
this.onPressDetected = true;
|
||||
const {
|
||||
onPress,
|
||||
post,
|
||||
showLongPost,
|
||||
} = this.props;
|
||||
|
||||
const isValidSystemMessage = fromAutoResponder(post) || !isSystemMessage(post);
|
||||
if (onPress && post.state !== Posts.POST_DELETED && isValidSystemMessage && !isPostPendingOrFailed(post)) {
|
||||
onPress(post);
|
||||
} else if ((isPostEphemeral(post) || post.state === Posts.POST_DELETED) && !showLongPost) {
|
||||
this.onRemovePost(post);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.onPressDetected = false;
|
||||
}, 300);
|
||||
});
|
||||
|
||||
handleReply = preventDoubleTap(() => {
|
||||
const {post, onReply} = this.props;
|
||||
if (onReply) {
|
||||
return onReply(post);
|
||||
}
|
||||
|
||||
return this.handlePress();
|
||||
});
|
||||
|
||||
onRemovePost = (post) => {
|
||||
const {removePost} = this.props.actions;
|
||||
removePost(post);
|
||||
};
|
||||
|
||||
isReplyPost = () => {
|
||||
const {renderReplies, post} = this.props;
|
||||
return Boolean(renderReplies && post.root_id && (!isPostEphemeral(post) || post.state === Posts.POST_DELETED));
|
||||
};
|
||||
|
||||
replyBarStyle = () => {
|
||||
const {
|
||||
commentedOnPost,
|
||||
isFirstReply,
|
||||
isLastReply,
|
||||
theme,
|
||||
isCommentMention,
|
||||
} = this.props;
|
||||
|
||||
if (!this.isReplyPost()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
const replyBarStyle = [style.replyBar];
|
||||
|
||||
if (isFirstReply || commentedOnPost) {
|
||||
replyBarStyle.push(style.replyBarFirst);
|
||||
}
|
||||
|
||||
if (isLastReply) {
|
||||
replyBarStyle.push(style.replyBarLast);
|
||||
}
|
||||
|
||||
if (isCommentMention) {
|
||||
replyBarStyle.push(style.commentMentionBgColor);
|
||||
}
|
||||
|
||||
return replyBarStyle;
|
||||
};
|
||||
|
||||
viewUserProfile = preventDoubleTap(() => {
|
||||
this.goToUserProfile();
|
||||
});
|
||||
|
||||
showPostOptions = () => {
|
||||
if (this.postBodyRef?.current && !this.onPressDetected) {
|
||||
this.postBodyRef.current.showPostOptions();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
testID,
|
||||
channelIsReadOnly,
|
||||
commentedOnPost,
|
||||
highlight,
|
||||
isLastPost,
|
||||
isLastReply,
|
||||
isSearchResult,
|
||||
onHashtagPress,
|
||||
onPermalinkPress,
|
||||
post,
|
||||
isBot,
|
||||
renderReplies,
|
||||
shouldRenderReplyButton,
|
||||
showAddReaction,
|
||||
showFullDate,
|
||||
showLongPost,
|
||||
theme,
|
||||
managedConfig,
|
||||
consecutivePost,
|
||||
hasComments,
|
||||
isFlagged,
|
||||
highlightPinnedOrFlagged,
|
||||
skipFlaggedHeader,
|
||||
skipPinnedHeader,
|
||||
location,
|
||||
previousPostExists,
|
||||
beforePrevPostUserId,
|
||||
} = this.props;
|
||||
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
const isReplyPost = this.isReplyPost();
|
||||
const mergeMessage = consecutivePost && !hasComments && !isBot;
|
||||
const highlightFlagged = isFlagged && !skipFlaggedHeader;
|
||||
const hightlightPinned = post.is_pinned && !skipPinnedHeader;
|
||||
|
||||
let highlighted;
|
||||
if (highlight) {
|
||||
highlighted = style.highlight;
|
||||
} else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrFlagged) {
|
||||
highlighted = style.highlightPinnedOrFlagged;
|
||||
}
|
||||
|
||||
let postHeader;
|
||||
let userProfile;
|
||||
let consecutiveStyle;
|
||||
|
||||
if (mergeMessage) {
|
||||
consecutiveStyle = {marginTop: 0};
|
||||
userProfile = <View style={style.consecutivePostContainer}/>;
|
||||
} else {
|
||||
userProfile = (
|
||||
<View style={[style.profilePictureContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
|
||||
<PostProfilePicture
|
||||
onViewUserProfile={this.viewUserProfile}
|
||||
post={post}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
postHeader = (
|
||||
<PostHeader
|
||||
post={post}
|
||||
commentedOnPost={commentedOnPost}
|
||||
createAt={post.create_at}
|
||||
isSearchResult={isSearchResult}
|
||||
shouldRenderReplyButton={shouldRenderReplyButton}
|
||||
showFullDate={showFullDate}
|
||||
onPress={this.handleReply}
|
||||
onUsernamePress={this.viewUserProfile}
|
||||
renderReplies={renderReplies}
|
||||
theme={theme}
|
||||
previousPostExists={previousPostExists}
|
||||
beforePrevPostUserId={beforePrevPostUserId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const replyBarStyle = this.replyBarStyle();
|
||||
const rightColumnStyle = [style.rightColumn, (commentedOnPost && isLastReply && style.rightColumnPadding)];
|
||||
const itemTestID = `${testID}.${post.id}`;
|
||||
|
||||
return (
|
||||
<View
|
||||
testID={testID}
|
||||
style={[style.postStyle, highlighted]}
|
||||
>
|
||||
<TouchableWithFeedback
|
||||
testID={itemTestID}
|
||||
onPress={this.handlePress}
|
||||
onLongPress={this.showPostOptions}
|
||||
delayLongPress={200}
|
||||
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
|
||||
cancelTouchOnPanning={true}
|
||||
>
|
||||
<>
|
||||
<PostPreHeader
|
||||
isConsecutive={mergeMessage}
|
||||
isFlagged={isFlagged}
|
||||
isPinned={post.is_pinned}
|
||||
rightColumnStyle={style.preHeaderRightColumn}
|
||||
skipFlaggedHeader={skipFlaggedHeader}
|
||||
skipPinnedHeader={skipPinnedHeader}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={[style.container, this.props.style, consecutiveStyle]}>
|
||||
{userProfile}
|
||||
<View style={rightColumnStyle}>
|
||||
{postHeader}
|
||||
<PostBody
|
||||
ref={this.postBodyRef}
|
||||
highlight={highlight}
|
||||
channelIsReadOnly={channelIsReadOnly}
|
||||
isLastPost={isLastPost}
|
||||
isSearchResult={isSearchResult}
|
||||
onFailedPostPress={this.handleFailedPostPress}
|
||||
onHashtagPress={onHashtagPress}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
onPress={this.handlePress}
|
||||
post={post}
|
||||
replyBarStyle={replyBarStyle}
|
||||
managedConfig={managedConfig}
|
||||
isFlagged={isFlagged}
|
||||
isReplyPost={isReplyPost}
|
||||
showAddReaction={showAddReaction}
|
||||
showLongPost={showLongPost}
|
||||
location={location}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
postStyle: {
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
pendingPost: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
preHeaderRightColumn: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
marginLeft: 2,
|
||||
},
|
||||
rightColumn: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
marginRight: 12,
|
||||
},
|
||||
rightColumnPadding: {
|
||||
paddingBottom: 3,
|
||||
},
|
||||
consecutivePostContainer: {
|
||||
marginBottom: 10,
|
||||
marginRight: 10,
|
||||
marginLeft: 47,
|
||||
marginTop: 10,
|
||||
},
|
||||
profilePictureContainer: {
|
||||
marginBottom: 5,
|
||||
marginLeft: 12,
|
||||
marginTop: 10,
|
||||
|
||||
// to compensate STATUS_BUFFER in profile_picture component
|
||||
...Platform.select({
|
||||
android: {
|
||||
marginRight: 11,
|
||||
},
|
||||
ios: {
|
||||
marginRight: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
replyBar: {
|
||||
backgroundColor: theme.centerChannelColor,
|
||||
opacity: 0.1,
|
||||
marginLeft: 1,
|
||||
marginRight: 7,
|
||||
width: 3,
|
||||
flexBasis: 3,
|
||||
},
|
||||
replyBarFirst: {
|
||||
paddingTop: 10,
|
||||
},
|
||||
replyBarLast: {
|
||||
paddingBottom: 10,
|
||||
},
|
||||
commentMentionBgColor: {
|
||||
backgroundColor: theme.mentionHighlightBg,
|
||||
opacity: 1,
|
||||
},
|
||||
highlight: {
|
||||
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5),
|
||||
},
|
||||
highlightPinnedOrFlagged: {
|
||||
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
|
||||
import {addChannelMember} from '@mm-redux/actions/channels';
|
||||
import {removePost} from '@mm-redux/actions/posts';
|
||||
|
||||
import {getPost} from '@mm-redux/selectors/entities/posts';
|
||||
import {getChannel} from '@mm-redux/selectors/entities/channels';
|
||||
import {getCurrentUser} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import {sendAddToChannelEphemeralPost} from 'app/actions/views/post';
|
||||
|
||||
import PostAddChannelMember from './post_add_channel_member';
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const post = getPost(state, ownProps.postId) || {};
|
||||
let channelType = '';
|
||||
if (post && post.channel_id) {
|
||||
const channel = getChannel(state, post.channel_id);
|
||||
if (channel && channel.type) {
|
||||
channelType = channel.type;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelType,
|
||||
currentUser: getCurrentUser(state),
|
||||
post,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
addChannelMember,
|
||||
removePost,
|
||||
sendAddToChannelEphemeralPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PostAddChannelMember);
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {Text} from 'react-native';
|
||||
|
||||
import AtMention from '@components/at_mention';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import {t} from '@utils/i18n';
|
||||
import {concatStyles} from '@utils/theme';
|
||||
|
||||
export default class PostAddChannelMember extends React.PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
addChannelMember: PropTypes.func.isRequired,
|
||||
removePost: PropTypes.func.isRequired,
|
||||
sendAddToChannelEphemeralPost: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
currentUser: PropTypes.object.isRequired,
|
||||
channelType: PropTypes.string,
|
||||
post: PropTypes.object.isRequired,
|
||||
postId: PropTypes.string.isRequired,
|
||||
userIds: PropTypes.array.isRequired,
|
||||
usernames: PropTypes.array.isRequired,
|
||||
noGroupsUsernames: PropTypes.array,
|
||||
onPostPress: PropTypes.func,
|
||||
textStyles: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
usernames: [],
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
computeTextStyle = (baseStyle, context) => {
|
||||
return concatStyles(baseStyle, context.map((type) => this.props.textStyles[type]));
|
||||
}
|
||||
|
||||
handleAddChannelMember = () => {
|
||||
const {
|
||||
actions,
|
||||
currentUser,
|
||||
post,
|
||||
userIds,
|
||||
usernames,
|
||||
} = this.props;
|
||||
|
||||
const {formatMessage} = this.context.intl;
|
||||
|
||||
if (post && post.channel_id) {
|
||||
userIds.forEach((userId, index) => {
|
||||
actions.addChannelMember(post.channel_id, userId);
|
||||
|
||||
if (post.root_id) {
|
||||
const message = formatMessage(
|
||||
{
|
||||
id: 'api.channel.add_member.added',
|
||||
defaultMessage: '{addedUsername} added to the channel by {username}.',
|
||||
},
|
||||
{
|
||||
username: currentUser.username,
|
||||
addedUsername: usernames[index],
|
||||
},
|
||||
);
|
||||
|
||||
actions.sendAddToChannelEphemeralPost(currentUser, usernames[index], message, post.channel_id, post.root_id);
|
||||
}
|
||||
});
|
||||
|
||||
actions.removePost(post);
|
||||
}
|
||||
}
|
||||
|
||||
generateAtMentions(usernames = [], textStyles) {
|
||||
if (usernames.length === 1) {
|
||||
return (
|
||||
<AtMention
|
||||
mentionStyle={this.props.textStyles.mention}
|
||||
mentionName={usernames[0]}
|
||||
onPostPress={this.props.onPostPress}
|
||||
/>
|
||||
);
|
||||
} else if (usernames.length > 1) {
|
||||
function andSeparator(key) {
|
||||
return (
|
||||
<FormattedText
|
||||
key={key}
|
||||
id={'post_body.check_for_out_of_channel_mentions.link.and'}
|
||||
defaultMessage={' and '}
|
||||
style={textStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function commaSeparator(key) {
|
||||
return <Text key={key}>{', '}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{
|
||||
usernames.map((username) => {
|
||||
return (
|
||||
<AtMention
|
||||
key={username}
|
||||
mentionStyle={this.props.textStyles.mention}
|
||||
mentionName={username}
|
||||
onPostPress={this.props.onPostPress}
|
||||
/>
|
||||
);
|
||||
}).reduce((acc, el, idx, arr) => {
|
||||
if (idx === 0) {
|
||||
return [el];
|
||||
} else if (idx === arr.length - 1) {
|
||||
return [...acc, andSeparator(idx), el];
|
||||
}
|
||||
|
||||
return [...acc, commaSeparator(idx), el];
|
||||
}, [])
|
||||
}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
render() {
|
||||
const {channelType, baseTextStyle, postId, usernames, noGroupsUsernames} = this.props;
|
||||
|
||||
if (!postId || !channelType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let linkId;
|
||||
let linkText;
|
||||
if (channelType === General.PRIVATE_CHANNEL) {
|
||||
linkId = t('post_body.check_for_out_of_channel_mentions.link.private');
|
||||
linkText = 'add them to this private channel';
|
||||
} else if (channelType === General.OPEN_CHANNEL) {
|
||||
linkId = t('post_body.check_for_out_of_channel_mentions.link.public');
|
||||
linkText = 'add them to the channel';
|
||||
}
|
||||
|
||||
let outOfChannelMessageID;
|
||||
let outOfChannelMessageText;
|
||||
const outOfChannelAtMentions = this.generateAtMentions(usernames, baseTextStyle);
|
||||
if (usernames.length === 1) {
|
||||
outOfChannelMessageID = t('post_body.check_for_out_of_channel_mentions.message.one');
|
||||
outOfChannelMessageText = 'was mentioned but is not in the channel. Would you like to ';
|
||||
} else if (usernames.length > 1) {
|
||||
outOfChannelMessageID = t('post_body.check_for_out_of_channel_mentions.message.multiple');
|
||||
outOfChannelMessageText = 'were mentioned but they are not in the channel. Would you like to ';
|
||||
}
|
||||
|
||||
let outOfGroupsMessageID;
|
||||
let outOfGroupsMessageText;
|
||||
const outOfGroupsAtMentions = this.generateAtMentions(noGroupsUsernames, baseTextStyle);
|
||||
if (noGroupsUsernames?.length) {
|
||||
outOfGroupsMessageID = t('post_body.check_for_out_of_channel_groups_mentions.message');
|
||||
outOfGroupsMessageText = 'did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.';
|
||||
}
|
||||
|
||||
let outOfChannelMessage = null;
|
||||
if (usernames.length) {
|
||||
outOfChannelMessage = (
|
||||
<Text>
|
||||
{outOfChannelAtMentions}
|
||||
{' '}
|
||||
<FormattedText
|
||||
id={outOfChannelMessageID}
|
||||
defaultMessage={outOfChannelMessageText}
|
||||
style={baseTextStyle}
|
||||
/>
|
||||
<Text
|
||||
style={this.props.textStyles.link}
|
||||
id='add_channel_member_link'
|
||||
onPress={this.handleAddChannelMember}
|
||||
>
|
||||
<FormattedText
|
||||
id={linkId}
|
||||
defaultMessage={linkText}
|
||||
/>
|
||||
</Text>
|
||||
<FormattedText
|
||||
id={'post_body.check_for_out_of_channel_mentions.message_last'}
|
||||
defaultMessage={'? They will have access to all message history.'}
|
||||
style={baseTextStyle}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
let outOfGroupsMessage = null;
|
||||
if (noGroupsUsernames?.length) {
|
||||
outOfGroupsMessage = (
|
||||
<Text>
|
||||
{outOfGroupsAtMentions}
|
||||
{' '}
|
||||
<FormattedText
|
||||
id={outOfGroupsMessageID}
|
||||
defaultMessage={outOfGroupsMessageText}
|
||||
style={baseTextStyle}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{outOfChannelMessage}
|
||||
{outOfGroupsMessage}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PostAttachmentImage should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"justifyContent": "flex-start",
|
||||
"marginBottom": 6,
|
||||
"marginTop": 10,
|
||||
},
|
||||
Object {
|
||||
"height": 100,
|
||||
},
|
||||
]
|
||||
}
|
||||
type="none"
|
||||
>
|
||||
<View>
|
||||
<Connect(ProgressiveImage)
|
||||
imageUri="uri"
|
||||
onError={[MockFunction]}
|
||||
resizeMode="contain"
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"borderRadius": 3,
|
||||
"justifyContent": "center",
|
||||
"marginVertical": 1,
|
||||
},
|
||||
Object {
|
||||
"height": 100,
|
||||
"width": 100,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {isGifTooLarge} from '@utils/images';
|
||||
|
||||
export default class PostAttachmentImage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
height: PropTypes.number.isRequired,
|
||||
id: PropTypes.string,
|
||||
imageMetadata: PropTypes.object,
|
||||
onError: PropTypes.func.isRequired,
|
||||
onImagePress: PropTypes.func.isRequired,
|
||||
uri: PropTypes.string,
|
||||
width: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
frameCount: 0,
|
||||
};
|
||||
|
||||
handlePress = () => {
|
||||
this.props.onImagePress();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (isGifTooLarge(this.props.imageMetadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note that TouchableWithoutFeedback only works if its child is a View
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePress}
|
||||
style={[styles.imageContainer, {height: this.props.height}]}
|
||||
type={'none'}
|
||||
>
|
||||
<View>
|
||||
<ProgressiveImage
|
||||
id={this.props.id}
|
||||
style={[styles.image, {width: this.props.width, height: this.props.height}]}
|
||||
imageUri={this.props.uri}
|
||||
resizeMode='contain'
|
||||
onError={this.props.onError}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
imageContainer: {
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: 6,
|
||||
marginTop: 10,
|
||||
},
|
||||
image: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 3,
|
||||
justifyContent: 'center',
|
||||
marginVertical: 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import PostAttachmentImage from './index';
|
||||
|
||||
describe('PostAttachmentImage', () => {
|
||||
const baseProps = {
|
||||
height: 100,
|
||||
width: 100,
|
||||
onError: jest.fn(),
|
||||
onImagePress: jest.fn(),
|
||||
uri: 'uri',
|
||||
};
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const wrapper = shallow(<PostAttachmentImage {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match snapshot, without image and description 1`] = `null`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match snapshot, without image and description 2`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={1}
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.5)",
|
||||
"fontSize": 12,
|
||||
"marginBottom": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
Mattermost
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={3}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#2389d7",
|
||||
"fontSize": 14,
|
||||
"marginBottom": 10,
|
||||
},
|
||||
Object {
|
||||
"marginRight": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Title
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"marginTop": 5,
|
||||
},
|
||||
Object {
|
||||
"height": 69.83261802575107,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
type="none"
|
||||
>
|
||||
<FastImage
|
||||
nativeID="image-123"
|
||||
resizeMode="contain"
|
||||
source={
|
||||
Object {
|
||||
"uri": "https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png",
|
||||
}
|
||||
}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"borderRadius": 3,
|
||||
},
|
||||
Object {
|
||||
"height": 69.83261802575107,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match snapshot, without site_name 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={3}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#2389d7",
|
||||
"fontSize": 14,
|
||||
"marginBottom": 10,
|
||||
},
|
||||
Object {
|
||||
"marginRight": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Title
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match snapshot, without title and url 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={3}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#2389d7",
|
||||
"fontSize": 14,
|
||||
"marginBottom": 10,
|
||||
},
|
||||
Object {
|
||||
"marginRight": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
https://mattermost.com/
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderDescription 1`] = `null`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderDescription 2`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={5}
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.7)",
|
||||
"fontSize": 13,
|
||||
"marginBottom": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
Description
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 1`] = `null`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 2`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"marginTop": 5,
|
||||
},
|
||||
Object {
|
||||
"height": 112.56666666666666,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
type="none"
|
||||
>
|
||||
<FastImage
|
||||
nativeID="image-123"
|
||||
resizeMode="contain"
|
||||
source={
|
||||
Object {
|
||||
"uri": "https://mattermost.com/logo.png",
|
||||
}
|
||||
}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"borderRadius": 3,
|
||||
},
|
||||
Object {
|
||||
"height": 112.56666666666666,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 3`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderColor": "rgba(61,60,64,0.2)",
|
||||
"borderRadius": 3,
|
||||
"borderWidth": 1,
|
||||
"flex": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={1}
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.5)",
|
||||
"fontSize": 12,
|
||||
"marginBottom": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
Mattermost
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={3}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#2389d7",
|
||||
"fontSize": 14,
|
||||
"marginBottom": 10,
|
||||
},
|
||||
Object {
|
||||
"marginRight": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Title
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getDimensions} from 'app/selectors/device';
|
||||
|
||||
import PostAttachmentOpenGraph from './post_attachment_opengraph';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
...getDimensions(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(PostAttachmentOpenGraph);
|
||||
|
|
@ -1,362 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Alert, Text, View} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import {intlShape} from 'react-intl';
|
||||
import parseUrl from 'url-parse';
|
||||
|
||||
import {TABLET_WIDTH} from '@components/sidebars/drawer_layout';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {DeviceTypes} from '@constants';
|
||||
import {generateId} from '@utils/file';
|
||||
import {openGalleryAtIndex, calculateDimensions} from '@utils/images';
|
||||
import {getNearestPoint} from '@utils/opengraph';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {tryOpenURL, isValidUrl} from '@utils/url';
|
||||
|
||||
const MAX_IMAGE_HEIGHT = 150;
|
||||
const VIEWPORT_IMAGE_OFFSET = 93;
|
||||
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
|
||||
|
||||
export default class PostAttachmentOpenGraph extends PureComponent {
|
||||
static propTypes = {
|
||||
deviceHeight: PropTypes.number.isRequired,
|
||||
deviceWidth: PropTypes.number.isRequired,
|
||||
imagesMetadata: PropTypes.object,
|
||||
isReplyPost: PropTypes.bool,
|
||||
link: PropTypes.string.isRequired,
|
||||
openGraphData: PropTypes.object,
|
||||
postId: PropTypes.string,
|
||||
theme: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.fileId = generateId();
|
||||
this.state = this.getBestImageUrlAndDimensions(props.openGraphData);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
|
||||
if (this.state.openGraphImageUrl) {
|
||||
this.getImageSize(this.state.openGraphImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
getBestImageUrlAndDimensions = (data) => {
|
||||
if (!data || !data.images) {
|
||||
return {
|
||||
hasImage: false,
|
||||
};
|
||||
}
|
||||
|
||||
const {imagesMetadata} = this.props;
|
||||
const bestDimensions = {
|
||||
width: this.getViewPostWidth(),
|
||||
height: MAX_IMAGE_HEIGHT,
|
||||
};
|
||||
|
||||
const bestImage = getNearestPoint(bestDimensions, data.images, 'width', 'height');
|
||||
const imageUrl = bestImage.secure_url || bestImage.url;
|
||||
|
||||
let ogImage;
|
||||
if (imagesMetadata && imagesMetadata[imageUrl]) {
|
||||
ogImage = imagesMetadata[imageUrl];
|
||||
}
|
||||
|
||||
if (!ogImage) {
|
||||
ogImage = data.images.find((i) => i.url === imageUrl || i.secure_url === imageUrl);
|
||||
}
|
||||
|
||||
// Fallback when the ogImage does not have dimensions but there is a metaImage defined
|
||||
const metaImages = imagesMetadata ? Object.values(imagesMetadata) : null;
|
||||
if ((!ogImage?.width || !ogImage?.height) && metaImages?.length) {
|
||||
ogImage = metaImages[0];
|
||||
}
|
||||
|
||||
let dimensions = bestDimensions;
|
||||
if (ogImage?.width && ogImage?.height) {
|
||||
dimensions = calculateDimensions(ogImage.height, ogImage.width, this.getViewPostWidth());
|
||||
}
|
||||
|
||||
return {
|
||||
hasImage: Boolean(isValidUrl(imageUrl) && (ogImage.width && ogImage.height)),
|
||||
...dimensions,
|
||||
openGraphImageUrl: imageUrl,
|
||||
};
|
||||
};
|
||||
|
||||
getFilename = (uri) => {
|
||||
const link = decodeURIComponent(uri);
|
||||
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
|
||||
const extension = filename.split('.').pop();
|
||||
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
}
|
||||
|
||||
return `og-${filename.replace(/:/g, '-')}`;
|
||||
};
|
||||
|
||||
getImageSize = (imageUrl) => {
|
||||
const {imagesMetadata, openGraphData} = this.props;
|
||||
const {openGraphImageUrl} = this.state;
|
||||
|
||||
let ogImage;
|
||||
if (imagesMetadata && imagesMetadata[openGraphImageUrl]) {
|
||||
ogImage = imagesMetadata[openGraphImageUrl];
|
||||
}
|
||||
|
||||
if (!ogImage) {
|
||||
ogImage = openGraphData?.images?.find((i) => i.url === openGraphImageUrl || i.secure_url === openGraphImageUrl);
|
||||
}
|
||||
|
||||
// Fallback when the ogImage does not have dimensions but there is a metaImage defined
|
||||
const metaImages = imagesMetadata ? Object.values(imagesMetadata) : null;
|
||||
if ((!ogImage?.width || !ogImage?.height) && metaImages?.length) {
|
||||
ogImage = metaImages[0];
|
||||
}
|
||||
|
||||
if (ogImage?.width && ogImage?.height) {
|
||||
this.setImageSize(imageUrl, ogImage.width, ogImage.height);
|
||||
}
|
||||
};
|
||||
|
||||
setImageSize = (imageUrl, originalWidth, originalHeight) => {
|
||||
if (this.mounted) {
|
||||
const dimensions = calculateDimensions(originalHeight, originalWidth, this.getViewPostWidth());
|
||||
|
||||
this.setState({
|
||||
imageUrl,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
...dimensions,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getViewPostWidth = () => {
|
||||
const {deviceHeight, deviceWidth, isReplyPost} = this.props;
|
||||
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
|
||||
const viewPortWidth = deviceSize - VIEWPORT_IMAGE_OFFSET - (isReplyPost ? VIEWPORT_IMAGE_REPLY_OFFSET : 0);
|
||||
const tabletOffset = DeviceTypes.IS_TABLET ? TABLET_WIDTH : 0;
|
||||
|
||||
return viewPortWidth - tabletOffset;
|
||||
};
|
||||
|
||||
goToLink = () => {
|
||||
const {intl} = this.context;
|
||||
const onError = () => {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.text',
|
||||
defaultMessage: 'Unable to open the link.',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
tryOpenURL(this.props.link, onError);
|
||||
};
|
||||
|
||||
handlePreviewImage = () => {
|
||||
const {
|
||||
imageUrl: uri,
|
||||
openGraphImageUrl: link,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
} = this.state;
|
||||
const filename = this.getFilename(link);
|
||||
const extension = filename.split('.').pop();
|
||||
|
||||
const files = [{
|
||||
id: this.fileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: this.props.postId,
|
||||
uri,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
}];
|
||||
|
||||
openGalleryAtIndex(0, files);
|
||||
};
|
||||
|
||||
renderDescription = () => {
|
||||
const {openGraphData} = this.props;
|
||||
if (!openGraphData.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
|
||||
return (
|
||||
<View style={style.flex}>
|
||||
<Text
|
||||
style={style.siteDescription}
|
||||
numberOfLines={5}
|
||||
ellipsizeMode='tail'
|
||||
>
|
||||
{openGraphData.description}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
renderImage = () => {
|
||||
if (!this.state.hasImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {height, openGraphImageUrl, width} = this.state;
|
||||
|
||||
let source;
|
||||
if (openGraphImageUrl) {
|
||||
source = {
|
||||
uri: openGraphImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
|
||||
return (
|
||||
<View style={[style.imageContainer, {width, height}]}>
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handlePreviewImage}
|
||||
type={'none'}
|
||||
>
|
||||
<FastImage
|
||||
style={[style.image, {width, height}]}
|
||||
source={source}
|
||||
resizeMode='contain'
|
||||
nativeID={`image-${this.fileId}`}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
isReplyPost,
|
||||
link,
|
||||
openGraphData,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
if (!openGraphData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let siteName;
|
||||
if (openGraphData.site_name) {
|
||||
siteName = (
|
||||
<View style={style.flex}>
|
||||
<Text
|
||||
style={style.siteTitle}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
>
|
||||
{openGraphData.site_name}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const title = openGraphData.title || openGraphData.url || link;
|
||||
let siteTitle;
|
||||
if (title) {
|
||||
siteTitle = (
|
||||
<View style={style.wrapper}>
|
||||
<TouchableWithFeedback
|
||||
style={style.flex}
|
||||
onPress={this.goToLink}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Text
|
||||
style={[style.siteSubtitle, {marginRight: isReplyPost ? 10 : 0}]}
|
||||
numberOfLines={3}
|
||||
ellipsizeMode='tail'
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
{siteName}
|
||||
{siteTitle}
|
||||
{this.renderDescription()}
|
||||
{this.renderImage()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
marginTop: 10,
|
||||
padding: 10,
|
||||
},
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
siteTitle: {
|
||||
fontSize: 12,
|
||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
marginBottom: 10,
|
||||
},
|
||||
siteSubtitle: {
|
||||
fontSize: 14,
|
||||
color: theme.linkColor,
|
||||
marginBottom: 10,
|
||||
},
|
||||
siteDescription: {
|
||||
fontSize: 13,
|
||||
color: changeOpacity(theme.centerChannelColor, 0.7),
|
||||
marginBottom: 10,
|
||||
},
|
||||
imageContainer: {
|
||||
alignItems: 'center',
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
marginTop: 5,
|
||||
},
|
||||
image: {
|
||||
borderRadius: 3,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
import PostAttachmentOpenGraph from './post_attachment_opengraph';
|
||||
|
||||
describe('PostAttachmentOpenGraph', () => {
|
||||
const openGraphData = {
|
||||
site_name: 'Mattermost',
|
||||
title: 'Title',
|
||||
url: 'https://mattermost.com/',
|
||||
images: [{
|
||||
secure_url: 'https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png',
|
||||
}],
|
||||
};
|
||||
const baseProps = {
|
||||
actions: {
|
||||
getOpenGraphMetadata: jest.fn(),
|
||||
},
|
||||
deviceHeight: 600,
|
||||
deviceWidth: 400,
|
||||
imagesMetadata: {
|
||||
'https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png': {
|
||||
width: 1165,
|
||||
height: 265,
|
||||
},
|
||||
},
|
||||
isReplyPost: false,
|
||||
link: 'https://mattermost.com/',
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
test('should match snapshot, without image and description', () => {
|
||||
let wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
|
||||
wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphData}
|
||||
/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('should match snapshot, without site_name', () => {
|
||||
const newOpenGraphData = {
|
||||
title: 'Title',
|
||||
url: 'https://mattermost.com/',
|
||||
};
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={newOpenGraphData}
|
||||
/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot, without title and url', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={{}}
|
||||
/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match state and snapshot, on renderImage', () => {
|
||||
let wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
expect(wrapper.instance().renderImage()).toMatchSnapshot();
|
||||
expect(wrapper.state('hasImage')).toEqual(false);
|
||||
expect(wrapper.find(FastImage).exists()).toEqual(false);
|
||||
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(false);
|
||||
|
||||
const images = [{height: 440, width: 1200, url: 'https://mattermost.com/logo.png'}];
|
||||
const openGraphDataWithImage = {...openGraphData, images};
|
||||
wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphDataWithImage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.instance().renderImage()).toMatchSnapshot();
|
||||
expect(wrapper.state('hasImage')).toEqual(true);
|
||||
expect(wrapper.find(FastImage).exists()).toEqual(true);
|
||||
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('should match state and snapshot, on renderImage', () => {
|
||||
const images = [{height: 440, width: 1200, url: '%REACT_APP_WEBSITE_BANNER%'}];
|
||||
const openGraphDataWithImage = {...openGraphData, images};
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphDataWithImage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.state('hasImage')).toEqual(false);
|
||||
expect(wrapper.find(FastImage).exists()).toEqual(false);
|
||||
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('should match state and snapshot, on renderDescription', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphData}
|
||||
/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
expect(wrapper.instance().renderDescription()).toMatchSnapshot();
|
||||
|
||||
const openGraphDataWithDescription = {...openGraphData, description: 'Description'};
|
||||
wrapper.setProps({openGraphData: openGraphDataWithDescription});
|
||||
expect(wrapper.instance().renderDescription()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match result on getFilename', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
const testCases = [
|
||||
{link: 'https://mattermost.com/image.png', result: 'og-image.png'},
|
||||
{link: 'https://mattermost.com/image.jpg', result: 'og-image.jpg'},
|
||||
{link: 'https://mattermost.com/image', result: 'og-image.png'},
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => { // eslint-disable-line max-nested-callbacks
|
||||
expect(wrapper.instance().getFilename(testCase.link)).toEqual(testCase.result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} updated the channel header from: {oldHeader} to: {newHeader}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for Channel Purpose update 1`] = `
|
||||
<Text
|
||||
style={Object {}}
|
||||
>
|
||||
{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}
|
||||
</Text>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for OLD archived channel without a username 1`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} archived the channel
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for archived channel 1`] = `
|
||||
<Connect(Markdown)
|
||||
baseTextStyle={Object {}}
|
||||
disableAtChannelMentionHighlight={true}
|
||||
disableGallery={true}
|
||||
onPostPress={[MockFunction]}
|
||||
textStyles={Object {}}
|
||||
value="{username} archived the channel"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for archived channel 2`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} archived the channel
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={Object {}}
|
||||
testID="markdown_text"
|
||||
>
|
||||
{username} unarchived the channel
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {General, Posts} from '@mm-redux/constants';
|
||||
import {getChannel, canManageChannelMembers, getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
|
||||
import {getCurrentUserId, getCurrentUserRoles, getUser} from '@mm-redux/selectors/entities/users';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {getCustomEmojisByName} from '@mm-redux/selectors/entities/emojis';
|
||||
import {makeGetReactionsForPost} from '@mm-redux/selectors/entities/posts';
|
||||
import {memoizeResult} from '@mm-redux/utils/helpers';
|
||||
import {makeGetMentionKeysForPost} from '@mm-redux/selectors/entities/search';
|
||||
|
||||
import {
|
||||
isEdited,
|
||||
isPostEphemeral,
|
||||
isSystemMessage,
|
||||
canDeletePost,
|
||||
} from '@mm-redux/utils/post_utils';
|
||||
import {isAdmin as checkIsAdmin, isSystemAdmin as checkIsSystemAdmin} from '@mm-redux/utils/user_utils';
|
||||
|
||||
import {getDimensions} from 'app/selectors/device';
|
||||
|
||||
import {hasEmojisOnly} from 'app/utils/emoji_utils';
|
||||
|
||||
import PostBody from './post_body';
|
||||
import {appsEnabled} from '@utils/apps';
|
||||
|
||||
const POST_TIMEOUT = 20000;
|
||||
|
||||
export function makeMapStateToProps() {
|
||||
const memoizeHasEmojisOnly = memoizeResult((message, customEmojis) => hasEmojisOnly(message, customEmojis));
|
||||
const getReactionsForPost = makeGetReactionsForPost();
|
||||
const getMentionKeysForPost = makeGetMentionKeysForPost();
|
||||
|
||||
return (state, ownProps) => {
|
||||
const post = ownProps.post;
|
||||
const channel = getChannel(state, post.channel_id) || {};
|
||||
const reactions = getReactionsForPost(state, post.id);
|
||||
|
||||
let isFailed = post.failed;
|
||||
let isPending = post.id === post.pending_post_id;
|
||||
if (isPending && Date.now() - post.create_at > POST_TIMEOUT) {
|
||||
// Something has prevented the post from being set to failed, so it's safe to assume
|
||||
// that it has actually failed by this point
|
||||
isFailed = true;
|
||||
isPending = false;
|
||||
}
|
||||
|
||||
const isUserCanManageMembers = canManageChannelMembers(state);
|
||||
const isEphemeralPost = isPostEphemeral(post);
|
||||
|
||||
const config = getConfig(state);
|
||||
const license = getLicense(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const currentTeamId = getCurrentTeamId(state);
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
|
||||
const isAdmin = checkIsAdmin(roles);
|
||||
const isSystemAdmin = checkIsSystemAdmin(roles);
|
||||
const channelIsArchived = channel?.delete_at !== 0; //eslint-disable-line camelcase
|
||||
let canDelete = false;
|
||||
|
||||
if (post && !channelIsArchived) {
|
||||
canDelete = canDeletePost(state, config, license, currentTeamId, currentChannelId, currentUserId, post, isAdmin, isSystemAdmin);
|
||||
}
|
||||
|
||||
let isPostAddChannelMember = false;
|
||||
if (
|
||||
channel &&
|
||||
(channel.type === General.PRIVATE_CHANNEL || channel.type === General.OPEN_CHANNEL) &&
|
||||
isUserCanManageMembers &&
|
||||
isEphemeralPost &&
|
||||
post.props &&
|
||||
post.props.add_channel_member
|
||||
) {
|
||||
isPostAddChannelMember = true;
|
||||
}
|
||||
|
||||
const customEmojis = getCustomEmojisByName(state);
|
||||
const {isEmojiOnly, shouldRenderJumboEmoji} = memoizeHasEmojisOnly(post.message, customEmojis);
|
||||
const systemMessage = isSystemMessage(post);
|
||||
const postProps = post.props || {};
|
||||
if (systemMessage && !postProps.username) {
|
||||
const owner = getUser(state, post.user_id);
|
||||
postProps.username = owner?.username || '';
|
||||
}
|
||||
|
||||
// Disable group highlight when post is pending
|
||||
if (post.id === post.pending_post_id) {
|
||||
postProps.disable_group_highlight = true;
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: post.metadata,
|
||||
postProps,
|
||||
postType: post.type || '',
|
||||
fileIds: post.file_ids,
|
||||
hasBeenDeleted: post.state === Posts.POST_DELETED,
|
||||
hasBeenEdited: isEdited(post),
|
||||
hasReactions: (reactions && Object.keys(reactions).length > 0) || Boolean(post.has_reactions),
|
||||
isFailed,
|
||||
isPending,
|
||||
isPostAddChannelMember,
|
||||
isPostEphemeral: isEphemeralPost,
|
||||
isSystemMessage: systemMessage,
|
||||
message: post.message,
|
||||
isEmojiOnly,
|
||||
shouldRenderJumboEmoji,
|
||||
theme: getTheme(state),
|
||||
mentionKeys: getMentionKeysForPost(state, channel, postProps?.disable_group_highlight, postProps?.mentionHighlightDisabled),
|
||||
canDelete,
|
||||
appsEnabled: appsEnabled(state),
|
||||
...getDimensions(state),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps, null, null, {forwardRef: true})(PostBody);
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getChannel} from '@mm-redux/selectors/entities/channels';
|
||||
import * as PostUtils from '@mm-redux/utils/post_utils';
|
||||
|
||||
import {makeMapStateToProps} from './index.js';
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/channels', () => {
|
||||
const channels = jest.requireActual('../../mm-redux/selectors/entities/channels');
|
||||
|
||||
return {
|
||||
...channels,
|
||||
getChannel: jest.fn(),
|
||||
canManageChannelMembers: jest.fn(),
|
||||
getCurrentChannelId: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/preferences', () => {
|
||||
const preferences = jest.requireActual('../../mm-redux/selectors/entities/preferences');
|
||||
return {
|
||||
...preferences,
|
||||
getTheme: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/general', () => {
|
||||
const general = jest.requireActual('../../mm-redux/selectors/entities/general');
|
||||
return {
|
||||
...general,
|
||||
getConfig: jest.fn(),
|
||||
getLicense: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/users', () => {
|
||||
const users = jest.requireActual('../../mm-redux/selectors/entities/users');
|
||||
return {
|
||||
...users,
|
||||
getCurrentUserId: jest.fn(),
|
||||
getCurrentUserRoles: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/teams', () => {
|
||||
const teams = jest.requireActual('../../mm-redux/selectors/entities/teams');
|
||||
return {
|
||||
...teams,
|
||||
getCurrentTeamId: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/emojis', () => {
|
||||
const emojis = jest.requireActual('../../mm-redux/selectors/entities/emojis');
|
||||
return {
|
||||
...emojis,
|
||||
getCustomEmojisByName: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/posts', () => {
|
||||
const posts = jest.requireActual('../../mm-redux/selectors/entities/posts');
|
||||
return {
|
||||
...posts,
|
||||
makeGetReactionsForPost: () => jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('app/selectors/device', () => ({
|
||||
getDimensions: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('makeMapStateToProps', () => {
|
||||
const defaultState = {
|
||||
entities: {
|
||||
general: {
|
||||
serverVersion: '',
|
||||
},
|
||||
users: {
|
||||
profiles: {},
|
||||
},
|
||||
groups: {
|
||||
groups: {},
|
||||
myGroups: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const defaultOwnProps = {
|
||||
post: {},
|
||||
};
|
||||
|
||||
test('should not call canDeletePost if post is not defined', () => {
|
||||
const canDeletePost = jest.spyOn(PostUtils, 'canDeletePost');
|
||||
const mapStateToProps = makeMapStateToProps();
|
||||
const ownProps = {
|
||||
post: '',
|
||||
};
|
||||
|
||||
const props = mapStateToProps(defaultState, ownProps);
|
||||
expect(props.canDelete).toBe(false);
|
||||
expect(canDeletePost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not call canDeletePost if post is defined and channel is archived', () => {
|
||||
const canDeletePost = jest.spyOn(PostUtils, 'canDeletePost');
|
||||
const mapStateToProps = makeMapStateToProps();
|
||||
|
||||
getChannel.mockReturnValueOnce({delete_at: 1}); //eslint-disable-line camelcase
|
||||
const props = mapStateToProps(defaultState, defaultOwnProps);
|
||||
expect(props.canDelete).toBe(false);
|
||||
expect(canDeletePost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should call canDeletePost if post is defined and channel is not archived', () => {
|
||||
const canDeletePost = jest.spyOn(PostUtils, 'canDeletePost');
|
||||
const mapStateToProps = makeMapStateToProps();
|
||||
|
||||
getChannel.mockReturnValue({delete_at: 0}); //eslint-disable-line camelcase
|
||||
mapStateToProps(defaultState, defaultOwnProps);
|
||||
expect(canDeletePost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,494 +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,
|
||||
ScrollView,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import CombinedSystemMessage from '@components/combined_system_message';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import Markdown from '@components/markdown';
|
||||
import MarkdownEmoji from '@components/markdown/markdown_emoji';
|
||||
import ShowMoreButton from '@components/show_more_button';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
|
||||
import {emptyFunction} from '@utils/general';
|
||||
import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {showModalOverCurrentContext} from '@actions/navigation';
|
||||
|
||||
import {renderSystemMessage} from './system_message_helpers';
|
||||
|
||||
let FileAttachmentList;
|
||||
let PostAddChannelMember;
|
||||
let PostBodyAdditionalContent;
|
||||
let Reactions;
|
||||
|
||||
const SHOW_MORE_HEIGHT = 60;
|
||||
|
||||
export default class PostBody extends PureComponent {
|
||||
static propTypes = {
|
||||
canDelete: PropTypes.bool,
|
||||
channelIsReadOnly: PropTypes.bool.isRequired,
|
||||
deviceHeight: PropTypes.number.isRequired,
|
||||
fileIds: PropTypes.array,
|
||||
hasBeenDeleted: PropTypes.bool,
|
||||
hasBeenEdited: PropTypes.bool,
|
||||
hasReactions: PropTypes.bool,
|
||||
highlight: PropTypes.bool,
|
||||
isFailed: PropTypes.bool,
|
||||
isFlagged: PropTypes.bool,
|
||||
isLastPost: PropTypes.bool,
|
||||
isPending: PropTypes.bool,
|
||||
isPostAddChannelMember: PropTypes.bool,
|
||||
isPostEphemeral: PropTypes.bool,
|
||||
isReplyPost: PropTypes.bool,
|
||||
isSearchResult: PropTypes.bool,
|
||||
isSystemMessage: PropTypes.bool,
|
||||
metadata: PropTypes.object,
|
||||
managedConfig: PropTypes.object,
|
||||
message: PropTypes.string,
|
||||
onFailedPostPress: PropTypes.func,
|
||||
onHashtagPress: PropTypes.func,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
onPress: PropTypes.func,
|
||||
post: PropTypes.object.isRequired,
|
||||
postProps: PropTypes.object.isRequired,
|
||||
postType: PropTypes.string,
|
||||
replyBarStyle: PropTypes.array,
|
||||
showAddReaction: PropTypes.bool,
|
||||
showLongPost: PropTypes.bool.isRequired,
|
||||
isEmojiOnly: PropTypes.bool.isRequired,
|
||||
shouldRenderJumboEmoji: PropTypes.bool.isRequired,
|
||||
theme: PropTypes.object,
|
||||
location: PropTypes.string,
|
||||
mentionKeys: PropTypes.array,
|
||||
appsEnabled: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
fileIds: [],
|
||||
onFailedPostPress: emptyFunction,
|
||||
onPress: emptyFunction,
|
||||
replyBarStyle: [],
|
||||
mentionKeys: [],
|
||||
message: '',
|
||||
postProps: {},
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
const maxHeight = Math.round((nextProps.deviceHeight * 0.6) + SHOW_MORE_HEIGHT);
|
||||
if (maxHeight !== prevState.maxHeight) {
|
||||
return {
|
||||
maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
state = {
|
||||
isLongPost: false,
|
||||
};
|
||||
|
||||
measurePost = (event) => {
|
||||
const {height} = event.nativeEvent.layout;
|
||||
const {showLongPost} = this.props;
|
||||
|
||||
if (!showLongPost) {
|
||||
this.setState({
|
||||
isLongPost: height >= this.state.maxHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
openLongPost = preventDoubleTap(() => {
|
||||
const {
|
||||
managedConfig,
|
||||
onHashtagPress,
|
||||
onPermalinkPress,
|
||||
post,
|
||||
} = this.props;
|
||||
const screen = 'LongPost';
|
||||
const passProps = {
|
||||
postId: post.id,
|
||||
managedConfig,
|
||||
onHashtagPress,
|
||||
onPermalinkPress,
|
||||
};
|
||||
const options = {
|
||||
layout: {
|
||||
componentBackgroundColor: changeOpacity('#000', 0.2),
|
||||
},
|
||||
};
|
||||
|
||||
showModalOverCurrentContext(screen, passProps, options);
|
||||
});
|
||||
|
||||
showPostOptions = () => {
|
||||
const {
|
||||
canDelete,
|
||||
channelIsReadOnly,
|
||||
hasBeenDeleted,
|
||||
isFailed,
|
||||
isFlagged,
|
||||
isPending,
|
||||
isPostEphemeral,
|
||||
isSystemMessage,
|
||||
managedConfig,
|
||||
post,
|
||||
showAddReaction,
|
||||
location,
|
||||
} = this.props;
|
||||
|
||||
if (isSystemMessage && (!canDelete || hasBeenDeleted)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPending || isFailed || isPostEphemeral) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screen = 'PostOptions';
|
||||
const passProps = {
|
||||
canDelete,
|
||||
channelIsReadOnly,
|
||||
hasBeenDeleted,
|
||||
isFlagged,
|
||||
isSystemMessage,
|
||||
post,
|
||||
managedConfig,
|
||||
showAddReaction,
|
||||
location,
|
||||
};
|
||||
|
||||
Keyboard.dismiss();
|
||||
requestAnimationFrame(() => {
|
||||
showModalOverCurrentContext(screen, passProps);
|
||||
});
|
||||
};
|
||||
|
||||
renderAddChannelMember = (messageStyle, textStyles) => {
|
||||
const {onPress, postProps} = this.props;
|
||||
|
||||
if (!PostAddChannelMember) {
|
||||
PostAddChannelMember = require('app/components/post_add_channel_member').default;
|
||||
}
|
||||
|
||||
const postId = postProps.add_channel_member.post_id;
|
||||
const noGroupsUsernames = postProps.add_channel_member?.not_in_groups_usernames; // eslint-disable-line camelcase
|
||||
let userIds = postProps.add_channel_member?.not_in_channel_user_ids; // eslint-disable-line camelcase
|
||||
let usernames = postProps.add_channel_member?.not_in_channel_usernames; // eslint-disable-line camelcase
|
||||
|
||||
if (!userIds) {
|
||||
userIds = postProps.add_channel_member?.user_ids; // eslint-disable-line camelcase
|
||||
}
|
||||
if (!usernames) {
|
||||
usernames = postProps.add_channel_member?.usernames;
|
||||
}
|
||||
|
||||
return (
|
||||
<PostAddChannelMember
|
||||
baseTextStyle={messageStyle}
|
||||
onPostPress={onPress}
|
||||
textStyles={textStyles}
|
||||
postId={postId}
|
||||
userIds={userIds}
|
||||
usernames={usernames}
|
||||
noGroupsUsernames={noGroupsUsernames}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderFileAttachments() {
|
||||
const {
|
||||
fileIds,
|
||||
isFailed,
|
||||
post,
|
||||
showLongPost,
|
||||
} = this.props;
|
||||
|
||||
if (showLongPost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let attachments;
|
||||
if (fileIds.length) {
|
||||
if (!FileAttachmentList) {
|
||||
FileAttachmentList = require('app/components/file_attachment_list').default;
|
||||
}
|
||||
|
||||
attachments = (
|
||||
<FileAttachmentList
|
||||
fileIds={fileIds}
|
||||
isFailed={isFailed}
|
||||
onLongPress={this.showPostOptions}
|
||||
postId={post.id}
|
||||
isReplyPost={this.props.isReplyPost}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
renderPostAdditionalContent = (blockStyles, messageStyle, textStyles) => {
|
||||
const {
|
||||
isPostEphemeral,
|
||||
isReplyPost,
|
||||
isSystemMessage,
|
||||
message,
|
||||
metadata,
|
||||
onHashtagPress,
|
||||
onPermalinkPress,
|
||||
post,
|
||||
postProps,
|
||||
appsEnabled,
|
||||
} = this.props;
|
||||
|
||||
if (isSystemMessage && !isPostEphemeral) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!metadata?.embeds?.length && !(appsEnabled && postProps.app_bindings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!PostBodyAdditionalContent) {
|
||||
PostBodyAdditionalContent = require('app/components/post_body_additional_content').default;
|
||||
}
|
||||
|
||||
return (
|
||||
<PostBodyAdditionalContent
|
||||
baseTextStyle={messageStyle}
|
||||
blockStyles={blockStyles}
|
||||
message={message}
|
||||
metadata={metadata}
|
||||
postId={post.id}
|
||||
postProps={postProps}
|
||||
textStyles={textStyles}
|
||||
isReplyPost={isReplyPost}
|
||||
onHashtagPress={onHashtagPress}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderReactions = () => {
|
||||
const {
|
||||
hasReactions,
|
||||
isSearchResult,
|
||||
post,
|
||||
showLongPost,
|
||||
} = this.props;
|
||||
|
||||
if (!hasReactions || isSearchResult || showLongPost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Reactions) {
|
||||
Reactions = require('app/components/reactions').default;
|
||||
}
|
||||
|
||||
return (
|
||||
<Reactions
|
||||
postId={post.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
hasBeenDeleted,
|
||||
hasBeenEdited,
|
||||
highlight,
|
||||
isEmojiOnly,
|
||||
isFailed,
|
||||
isPending,
|
||||
isPostAddChannelMember,
|
||||
isReplyPost,
|
||||
isSearchResult,
|
||||
isSystemMessage,
|
||||
message,
|
||||
metadata,
|
||||
onFailedPostPress,
|
||||
onHashtagPress,
|
||||
onPermalinkPress,
|
||||
onPress,
|
||||
post,
|
||||
postProps,
|
||||
postType,
|
||||
replyBarStyle,
|
||||
shouldRenderJumboEmoji,
|
||||
showLongPost,
|
||||
theme,
|
||||
mentionKeys,
|
||||
} = this.props;
|
||||
const {isLongPost, maxHeight} = this.state;
|
||||
const style = getStyleSheet(theme);
|
||||
const blockStyles = getMarkdownBlockStyles(theme);
|
||||
const textStyles = getMarkdownTextStyles(theme);
|
||||
const messageStyle = isSystemMessage ? [style.message, style.systemMessage] : style.message;
|
||||
const isPendingOrFailedPost = isPending || isFailed;
|
||||
|
||||
const messageStyles = {messageStyle, textStyles};
|
||||
const intl = this.context.intl;
|
||||
const systemMessage = renderSystemMessage(this.props, messageStyles, intl);
|
||||
|
||||
if (systemMessage) {
|
||||
return systemMessage;
|
||||
}
|
||||
|
||||
let body;
|
||||
let messageComponent;
|
||||
if (hasBeenDeleted) {
|
||||
body = (
|
||||
<FormattedText
|
||||
style={messageStyle}
|
||||
id='post_body.deleted'
|
||||
defaultMessage='(message deleted)'
|
||||
/>
|
||||
);
|
||||
} else if (isPostAddChannelMember) {
|
||||
messageComponent = this.renderAddChannelMember(messageStyle, textStyles);
|
||||
} else if (postType === Posts.POST_TYPES.COMBINED_USER_ACTIVITY) {
|
||||
const {allUserIds, allUsernames, messageData} = postProps.user_activity;
|
||||
messageComponent = (
|
||||
<CombinedSystemMessage
|
||||
allUserIds={allUserIds}
|
||||
allUsernames={allUsernames}
|
||||
linkStyle={textStyles.link}
|
||||
messageData={messageData}
|
||||
textStyles={textStyles}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else if (isEmojiOnly) {
|
||||
messageComponent = (
|
||||
<MarkdownEmoji
|
||||
baseTextStyle={messageStyle}
|
||||
isEdited={hasBeenEdited}
|
||||
shouldRenderJumboEmoji={shouldRenderJumboEmoji}
|
||||
value={message}
|
||||
/>
|
||||
);
|
||||
} else if (message.length) {
|
||||
messageComponent = (
|
||||
<View
|
||||
style={[style.messageContainer, (isReplyPost && style.reply), (isPendingOrFailedPost && style.pendingPost)]}
|
||||
onLayout={this.measurePost}
|
||||
removeClippedSubviews={isLongPost}
|
||||
>
|
||||
<Markdown
|
||||
baseTextStyle={messageStyle}
|
||||
blockStyles={blockStyles}
|
||||
channelMentions={postProps.channel_mentions}
|
||||
imagesMetadata={metadata?.images}
|
||||
isEdited={hasBeenEdited}
|
||||
isReplyPost={isReplyPost}
|
||||
isSearchResult={isSearchResult}
|
||||
onHashtagPress={onHashtagPress}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
onPostPress={onPress}
|
||||
postId={post.id}
|
||||
textStyles={textStyles}
|
||||
value={message}
|
||||
mentionKeys={mentionKeys}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasBeenDeleted) {
|
||||
body = (
|
||||
<View style={style.messageBody}>
|
||||
<ScrollView
|
||||
style={{maxHeight: (showLongPost ? null : maxHeight), overflow: 'hidden'}}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps={'always'}
|
||||
>
|
||||
{messageComponent}
|
||||
</ScrollView>
|
||||
{isLongPost &&
|
||||
<ShowMoreButton
|
||||
highlight={highlight}
|
||||
onPress={this.openLongPost}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
{this.renderPostAdditionalContent(blockStyles, messageStyle, textStyles)}
|
||||
{this.renderFileAttachments()}
|
||||
{this.renderReactions()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.messageContainerWithReplyBar}>
|
||||
<View style={replyBarStyle}/>
|
||||
{body}
|
||||
{isFailed &&
|
||||
<TouchableWithFeedback
|
||||
onPress={onFailedPostPress}
|
||||
style={style.retry}
|
||||
type={'opacity'}
|
||||
>
|
||||
<CompassIcon
|
||||
name='information-outline'
|
||||
size={26}
|
||||
color={theme.errorTextColor}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
messageBody: {
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2,
|
||||
flex: 1,
|
||||
},
|
||||
messageContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
reply: {
|
||||
paddingRight: 10,
|
||||
},
|
||||
retry: {
|
||||
justifyContent: 'center',
|
||||
marginLeft: 10,
|
||||
},
|
||||
message: {
|
||||
color: theme.centerChannelColor,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
},
|
||||
messageContainerWithReplyBar: {
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
},
|
||||
pendingPost: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
systemMessage: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
|
||||
import PostBodyAdditionalContent from 'app/components/post_body_additional_content';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import PostBody from './post_body.js';
|
||||
import * as SystemMessageHelpers from './system_message_helpers';
|
||||
|
||||
jest.mock('./system_message_helpers');
|
||||
|
||||
describe('PostBody', () => {
|
||||
const baseProps = {
|
||||
canDelete: true,
|
||||
channelIsReadOnly: false,
|
||||
deviceHeight: 1920,
|
||||
fileIds: [],
|
||||
hasBeenDeleted: false,
|
||||
hasBeenEdited: false,
|
||||
hasReactions: false,
|
||||
highlight: false,
|
||||
isFailed: false,
|
||||
isFlagged: false,
|
||||
isPending: false,
|
||||
isPostAddChannelMember: false,
|
||||
isPostEphemeral: false,
|
||||
isReplyPost: false,
|
||||
isSearchResult: false,
|
||||
isSystemMessage: false,
|
||||
managedConfig: {},
|
||||
message: 'Hello, World!',
|
||||
onFailedPostPress: jest.fn(),
|
||||
onHashtagPress: jest.fn(),
|
||||
onPermalinkPress: jest.fn(),
|
||||
onPress: jest.fn(),
|
||||
post: {id: 'post'},
|
||||
postProps: {},
|
||||
postType: '',
|
||||
replyBarStyle: [],
|
||||
showAddReaction: true,
|
||||
showLongPost: true,
|
||||
isEmojiOnly: false,
|
||||
shouldRenderJumboEmoji: false,
|
||||
theme: Preferences.THEMES.default,
|
||||
appsEnabled: false,
|
||||
};
|
||||
|
||||
test('should mount additional content for non-system messages', () => {
|
||||
const metadata = {
|
||||
embeds: [{
|
||||
type: 'opengraph',
|
||||
url: 'https://youtu.be/g-EXttb1C9A',
|
||||
}],
|
||||
images: {
|
||||
'https://i.ytimg.com/vi/g-EXttb1C9A/maxresdefault.jpg': {
|
||||
format: 'jpeg',
|
||||
frame_count: 0,
|
||||
height: 720,
|
||||
width: 1280,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
isSystemMessage: false,
|
||||
metadata,
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(<PostBody {...props}/>);
|
||||
|
||||
expect(wrapper.find(PostBodyAdditionalContent).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should not mount additional content for system messages', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isSystemMessage: true,
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(<PostBody {...props}/>);
|
||||
|
||||
expect(wrapper.find(PostBodyAdditionalContent).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('measurePost should update isLongPost when showLongPost is false', () => {
|
||||
const event = {
|
||||
nativeEvent: {
|
||||
layout: {
|
||||
height: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const props = {...baseProps, showLongPost: false};
|
||||
const wrapper = shallowWithIntl(<PostBody {...props}/>);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
expect(wrapper.state('isLongPost')).toEqual(false);
|
||||
|
||||
event.nativeEvent.layout.height = wrapper.state('maxHeight');
|
||||
instance.measurePost(event);
|
||||
expect(wrapper.state('isLongPost')).toEqual(true);
|
||||
|
||||
event.nativeEvent.layout.height = wrapper.state('maxHeight') - 1;
|
||||
instance.measurePost(event);
|
||||
expect(wrapper.state('isLongPost')).toEqual(false);
|
||||
event.nativeEvent.layout.height = wrapper.state('maxHeight') + 1;
|
||||
instance.measurePost(event);
|
||||
expect(wrapper.state('isLongPost')).toEqual(true);
|
||||
});
|
||||
|
||||
test('measurePost should not update isLongPost when showLongPost is true', () => {
|
||||
const event = {
|
||||
nativeEvent: {
|
||||
layout: {
|
||||
height: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const props = {...baseProps, showLongPost: true};
|
||||
const wrapper = shallowWithIntl(<PostBody {...props}/>);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
expect(wrapper.state('isLongPost')).toEqual(false);
|
||||
|
||||
event.nativeEvent.layout.height = wrapper.state('maxHeight');
|
||||
instance.measurePost(event);
|
||||
expect(wrapper.state('isLongPost')).toEqual(false);
|
||||
|
||||
event.nativeEvent.layout.height = wrapper.state('maxHeight') - 1;
|
||||
instance.measurePost(event);
|
||||
expect(wrapper.state('isLongPost')).toEqual(false);
|
||||
|
||||
event.nativeEvent.layout.height = wrapper.state('maxHeight') + 1;
|
||||
instance.measurePost(event);
|
||||
expect(wrapper.state('isLongPost')).toEqual(false);
|
||||
});
|
||||
|
||||
test('should return system message as post body', () => {
|
||||
shallowWithIntl(<PostBody {...baseProps}/>);
|
||||
expect(SystemMessageHelpers.renderSystemMessage.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
import {renderWithRedux} from 'test/testing_library';
|
||||
|
||||
import * as SystemMessageHelpers from './system_message_helpers';
|
||||
|
||||
const basePostBodyProps = {
|
||||
postProps: {
|
||||
username: 'username',
|
||||
},
|
||||
onPress: jest.fn(),
|
||||
};
|
||||
|
||||
const mockStyles = {
|
||||
messageStyle: {},
|
||||
textStyles: {},
|
||||
};
|
||||
|
||||
const mockIntl = {
|
||||
formatMessage: ({defaultMessage}) => defaultMessage,
|
||||
};
|
||||
|
||||
describe('renderSystemMessage', () => {
|
||||
test('uses renderer for Channel Header update', () => {
|
||||
const postBodyProps = {
|
||||
...basePostBodyProps,
|
||||
postProps: {
|
||||
...basePostBodyProps.postProps,
|
||||
old_header: 'old header',
|
||||
new_header: 'new header',
|
||||
},
|
||||
postType: Posts.POST_TYPES.HEADER_CHANGE,
|
||||
};
|
||||
const renderedMessage = SystemMessageHelpers.renderSystemMessage(postBodyProps, mockStyles, mockIntl);
|
||||
const {getByText, toJSON} = renderWithRedux(renderedMessage);
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
expect(getByText('{username} updated the channel header from: {oldHeader} to: {newHeader}')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('uses renderer for Channel Display Name update', () => {
|
||||
const postBodyProps = {
|
||||
...basePostBodyProps,
|
||||
postProps: {
|
||||
...basePostBodyProps.postProps,
|
||||
old_displayname: 'old displayname',
|
||||
new_displayname: 'new displayname',
|
||||
},
|
||||
postType: Posts.POST_TYPES.DISPLAYNAME_CHANGE,
|
||||
};
|
||||
|
||||
const renderedMessage = SystemMessageHelpers.renderSystemMessage(postBodyProps, mockStyles, mockIntl);
|
||||
const {getByText, toJSON} = renderWithRedux(renderedMessage);
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
expect(getByText('{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('uses renderer for Channel Purpose update', () => {
|
||||
const postBodyProps = {
|
||||
...basePostBodyProps,
|
||||
postProps: {
|
||||
...basePostBodyProps.postProps,
|
||||
old_purpose: 'old purpose',
|
||||
new_purpose: 'new purpose',
|
||||
},
|
||||
postType: Posts.POST_TYPES.PURPOSE_CHANGE,
|
||||
};
|
||||
|
||||
const renderedMessage = SystemMessageHelpers.renderSystemMessage(postBodyProps, mockStyles, mockIntl);
|
||||
const {getByText, toJSON} = renderWithRedux(renderedMessage);
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
expect(getByText('{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('uses renderer for archived channel', () => {
|
||||
const postBodyProps = {
|
||||
...basePostBodyProps,
|
||||
postProps: {
|
||||
...basePostBodyProps.postProps,
|
||||
},
|
||||
postType: Posts.POST_TYPES.CHANNEL_DELETED,
|
||||
};
|
||||
|
||||
const renderedMessage = SystemMessageHelpers.renderSystemMessage(postBodyProps, mockStyles, mockIntl);
|
||||
expect(renderedMessage).toMatchSnapshot();
|
||||
const {getByText, toJSON} = renderWithRedux(renderedMessage);
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
expect(getByText('{username} archived the channel')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('uses renderer for OLD archived channel without a username', () => {
|
||||
const postBodyProps = {
|
||||
...basePostBodyProps,
|
||||
postProps: {},
|
||||
postType: Posts.POST_TYPES.CHANNEL_DELETED,
|
||||
};
|
||||
|
||||
const renderedMessage = SystemMessageHelpers.renderSystemMessage(postBodyProps, mockStyles, mockIntl);
|
||||
const {getByText, toJSON} = renderWithRedux(renderedMessage);
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
expect(getByText('{username} archived the channel')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('uses renderer for unarchived channel', () => {
|
||||
const postBodyProps = {
|
||||
...basePostBodyProps,
|
||||
postProps: {
|
||||
...basePostBodyProps.postProps,
|
||||
},
|
||||
postType: Posts.POST_TYPES.CHANNEL_UNARCHIVED,
|
||||
};
|
||||
|
||||
let renderedMessage = SystemMessageHelpers.renderSystemMessage(postBodyProps, mockStyles, mockIntl);
|
||||
const viewOne = renderWithRedux(renderedMessage);
|
||||
expect(viewOne.toJSON()).toMatchSnapshot();
|
||||
expect(viewOne.getByText('{username} unarchived the channel')).toBeTruthy();
|
||||
|
||||
const noUserInPostBodyProps = {
|
||||
...basePostBodyProps,
|
||||
postProps: {},
|
||||
postType: Posts.POST_TYPES.CHANNEL_UNARCHIVED,
|
||||
};
|
||||
|
||||
renderedMessage = SystemMessageHelpers.renderSystemMessage(noUserInPostBodyProps, mockStyles, mockIntl);
|
||||
const viewTwo = renderWithRedux(renderedMessage);
|
||||
expect(viewTwo.toJSON()).toBeNull();
|
||||
expect(viewTwo.queryByText('{username} archived the channel')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('is null for non-qualifying system messages', () => {
|
||||
const postBodyProps = {
|
||||
...basePostBodyProps,
|
||||
postType: 'not_relevant',
|
||||
};
|
||||
|
||||
const renderedMessage = SystemMessageHelpers.renderSystemMessage(postBodyProps, mockStyles, mockIntl);
|
||||
expect(renderedMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +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 {getRedirectLocation} from '@mm-redux/actions/general';
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import {getOpenGraphMetadataForUrl as selectOpenGraphMetadataForUrl, getExpandedLink as selectExpandedLink} from '@mm-redux/selectors/entities/posts';
|
||||
import {getBool, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import {getDimensions} from 'app/selectors/device';
|
||||
|
||||
import PostBodyAdditionalContent from './post_body_additional_content';
|
||||
import {appsEnabled} from '@utils/apps';
|
||||
|
||||
function selectOpenGraphData(metadata, url) {
|
||||
if (!metadata || !metadata.embeds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata.embeds.find((embed) => {
|
||||
return embed.type === 'opengraph' && embed.url === url ? embed.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const config = getConfig(state);
|
||||
const link = ownProps.metadata?.embeds?.[0]?.url || '';
|
||||
let expandedLink;
|
||||
if (link) {
|
||||
expandedLink = selectExpandedLink(state, link);
|
||||
}
|
||||
|
||||
// Link previews used to be an advanced settings until server version 4.4 when it was changed to be a display setting.
|
||||
// We are checking both here until we bump the server requirement for the mobile apps.
|
||||
const previewsEnabled = (getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, `${ViewTypes.FEATURE_TOGGLE_PREFIX}${ViewTypes.EMBED_PREVIEW}`) ||
|
||||
getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY, true));
|
||||
|
||||
const removeLinkPreview = ownProps.postProps.remove_link_preview === 'true';
|
||||
|
||||
let openGraphData = selectOpenGraphMetadataForUrl(state, ownProps.postId, link);
|
||||
if (!openGraphData) {
|
||||
const data = selectOpenGraphData(ownProps.metadata, link);
|
||||
openGraphData = data?.data;
|
||||
}
|
||||
|
||||
return {
|
||||
...getDimensions(state),
|
||||
googleDeveloperKey: config.GoogleDeveloperKey,
|
||||
link,
|
||||
expandedLink,
|
||||
openGraphData,
|
||||
showLinkPreviews: previewsEnabled && config.EnableLinkPreviews === 'true' && !removeLinkPreview,
|
||||
theme: getTheme(state),
|
||||
appsEnabled: appsEnabled(state),
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
getRedirectLocation,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PostBodyAdditionalContent);
|
||||
|
|
@ -1,579 +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 {
|
||||
Alert,
|
||||
Image,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube';
|
||||
import {intlShape} from 'react-intl';
|
||||
import parseUrl from 'url-parse';
|
||||
|
||||
import ImageViewPort from '@components/image_viewport';
|
||||
import PostAttachmentImage from '@components/post_attachment_image';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {generateId} from '@utils/file';
|
||||
import {calculateDimensions, getViewPortWidth, openGalleryAtIndex} from '@utils/images';
|
||||
import {getYouTubeVideoId, isImageLink, isYoutubeLink, tryOpenURL} from '@utils/url';
|
||||
import EmbeddedBindings from '@components/embedded_bindings';
|
||||
|
||||
const MAX_YOUTUBE_IMAGE_HEIGHT = 202;
|
||||
const MAX_YOUTUBE_IMAGE_WIDTH = 360;
|
||||
const MAX_IMAGE_HEIGHT = 150;
|
||||
let MessageAttachments;
|
||||
let PostAttachmentOpenGraph;
|
||||
|
||||
export default class PostBodyAdditionalContent extends ImageViewPort {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
getRedirectLocation: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
|
||||
blockStyles: PropTypes.object,
|
||||
deviceHeight: PropTypes.number.isRequired,
|
||||
deviceWidth: PropTypes.number.isRequired,
|
||||
expandedLink: PropTypes.string,
|
||||
googleDeveloperKey: PropTypes.string,
|
||||
isReplyPost: PropTypes.bool,
|
||||
link: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
metadata: PropTypes.object,
|
||||
onHashtagPress: PropTypes.func,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
openGraphData: PropTypes.object,
|
||||
postId: PropTypes.string.isRequired,
|
||||
postProps: PropTypes.object.isRequired,
|
||||
showLinkPreviews: PropTypes.bool.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
textStyles: PropTypes.object,
|
||||
appsEnabled: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
let dimensions = {
|
||||
height: 0,
|
||||
width: 0,
|
||||
};
|
||||
|
||||
if (this.isImage() && props.metadata && props.metadata.images) {
|
||||
const img = props.metadata.images[props.link];
|
||||
if (img && img.height && img.width) {
|
||||
dimensions = calculateDimensions(img.height, img.width, getViewPortWidth(props.isReplyPost, this.hasPermanentSidebar()));
|
||||
}
|
||||
}
|
||||
|
||||
this.fileId = generateId();
|
||||
this.state = {
|
||||
linkLoadError: false,
|
||||
linkLoaded: false,
|
||||
...dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
super.componentDidMount();
|
||||
this.load();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.link !== this.props.link) {
|
||||
this.load(true);
|
||||
}
|
||||
}
|
||||
|
||||
generateToggleableEmbed = (isImage, isYouTube) => {
|
||||
let {link} = this.props;
|
||||
const {expandedLink} = this.props;
|
||||
if (expandedLink) {
|
||||
link = expandedLink;
|
||||
}
|
||||
|
||||
if (link) {
|
||||
if (isYouTube) {
|
||||
return this.renderYouTubeVideo(link);
|
||||
}
|
||||
|
||||
if (isImage) {
|
||||
return this.renderImage(link);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getFileInfo = () => {
|
||||
let {link} = this.props;
|
||||
const {originalHeight, originalWidth, uri} = this.state;
|
||||
const {expandedLink, postId} = this.props;
|
||||
if (expandedLink) {
|
||||
link = expandedLink;
|
||||
}
|
||||
|
||||
const url = decodeURIComponent(link);
|
||||
let filename = parseUrl(url.substr(url.lastIndexOf('/'))).pathname.replace('/', '');
|
||||
let extension = filename.split('.').pop();
|
||||
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
extension = ext;
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.fileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: postId,
|
||||
uri,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
};
|
||||
};
|
||||
|
||||
getImageSize = (path) => {
|
||||
const {link, metadata} = this.props;
|
||||
let img;
|
||||
|
||||
if (link && path) {
|
||||
if (metadata && metadata.images) {
|
||||
img = metadata.images[link];
|
||||
}
|
||||
|
||||
if (img && img.height && img.width) {
|
||||
this.setImageSize(path, img.width, img.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getImageUrl = (link) => {
|
||||
let imageUrl;
|
||||
|
||||
if (this.isImage()) {
|
||||
imageUrl = link;
|
||||
} else if (isYoutubeLink(link)) {
|
||||
const videoId = getYouTubeVideoId(link);
|
||||
const images = Object.keys(this.props.metadata?.images || {});
|
||||
imageUrl = images[0] || `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
};
|
||||
|
||||
getYouTubeTime = (link) => {
|
||||
const timeRegex = /[\\?&](t|start|time_continue)=([0-9]+h)?([0-9]+m)?([0-9]+s?)/;
|
||||
|
||||
const time = link.match(timeRegex);
|
||||
if (!time || !time[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const hours = time[2] ? time[2].match(/([0-9]+)h/) : null;
|
||||
const minutes = time[3] ? time[3].match(/([0-9]+)m/) : null;
|
||||
const seconds = time[4] ? time[4].match(/([0-9]+)s?/) : null;
|
||||
|
||||
let ticks = 0;
|
||||
|
||||
if (hours && hours[1]) {
|
||||
ticks += parseInt(hours[1], 10) * 3600;
|
||||
}
|
||||
|
||||
if (minutes && minutes[1]) {
|
||||
ticks += parseInt(minutes[1], 10) * 60;
|
||||
}
|
||||
|
||||
if (seconds && seconds[1]) {
|
||||
ticks += parseInt(seconds[1], 10);
|
||||
}
|
||||
|
||||
return ticks;
|
||||
};
|
||||
|
||||
handleLinkLoadError = () => {
|
||||
this.setState({linkLoadError: true});
|
||||
};
|
||||
|
||||
handlePreviewImage = () => {
|
||||
const files = [this.getFileInfo()];
|
||||
|
||||
openGalleryAtIndex(0, files);
|
||||
};
|
||||
|
||||
isImage = (specificLink) => {
|
||||
const {metadata, link} = this.props;
|
||||
|
||||
if (isImageLink(specificLink || link)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (metadata && metadata.images) {
|
||||
return Boolean(metadata.images[specificLink] || metadata.images[link]);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
load = (linkChanged = false) => {
|
||||
const {link, expandedLink, actions} = this.props;
|
||||
|
||||
if (link) {
|
||||
if (isYoutubeLink(link)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let imageUrl = this.getImageUrl(link);
|
||||
|
||||
if (!imageUrl) {
|
||||
if (!expandedLink || linkChanged) {
|
||||
actions.getRedirectLocation(link);
|
||||
} else {
|
||||
imageUrl = this.getImageUrl(expandedLink);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
this.getImageSize(imageUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
playYouTubeVideo = () => {
|
||||
const {expandedLink, link} = this.props;
|
||||
const videoLink = expandedLink || link;
|
||||
const videoId = getYouTubeVideoId(videoLink);
|
||||
const startTime = this.getYouTubeTime(videoLink);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
YouTubeStandaloneIOS.
|
||||
playVideo(videoId, startTime).
|
||||
then(this.playYouTubeVideoEnded).
|
||||
catch(this.playYouTubeVideoError);
|
||||
} else {
|
||||
const {googleDeveloperKey} = this.props;
|
||||
|
||||
if (googleDeveloperKey) {
|
||||
YouTubeStandaloneAndroid.playVideo({
|
||||
apiKey: googleDeveloperKey,
|
||||
videoId,
|
||||
autoplay: true,
|
||||
startTime,
|
||||
}).catch(this.playYouTubeVideoError);
|
||||
} else {
|
||||
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(videoLink, onError);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
playYouTubeVideoEnded = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
StatusBar.setHidden(false);
|
||||
EventEmitter.emit('update_safe_area_view');
|
||||
}
|
||||
};
|
||||
|
||||
playYouTubeVideoError = (errorMessage) => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
|
||||
Alert.alert(
|
||||
formatMessage({
|
||||
id: 'mobile.youtube_playback_error.title',
|
||||
defaultMessage: 'YouTube playback error',
|
||||
}),
|
||||
formatMessage({
|
||||
id: 'mobile.youtube_playback_error.description',
|
||||
defaultMessage: 'An error occurred while trying to play the YouTube video.\nDetails: {details}',
|
||||
}, {
|
||||
details: errorMessage,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
renderImage = (link) => {
|
||||
const imageMetadata = this.props.metadata?.images?.[link];
|
||||
const fileInfo = this.getFileInfo();
|
||||
const {width, height, uri} = this.state;
|
||||
|
||||
if (!imageMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PostAttachmentImage
|
||||
id={fileInfo.id}
|
||||
height={height || MAX_IMAGE_HEIGHT}
|
||||
imageMetadata={imageMetadata}
|
||||
onImagePress={this.handlePreviewImage}
|
||||
onError={this.handleLinkLoadError}
|
||||
uri={uri}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderMessageAttachment = () => {
|
||||
const {
|
||||
postId,
|
||||
postProps,
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
deviceHeight,
|
||||
deviceWidth,
|
||||
metadata,
|
||||
onHashtagPress,
|
||||
onPermalinkPress,
|
||||
textStyles,
|
||||
theme,
|
||||
} = this.props;
|
||||
const {attachments} = postProps;
|
||||
|
||||
if (attachments && attachments.length) {
|
||||
if (!MessageAttachments) {
|
||||
MessageAttachments = require('@components/message_attachments').default;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageAttachments
|
||||
attachments={attachments}
|
||||
baseTextStyle={baseTextStyle}
|
||||
blockStyles={blockStyles}
|
||||
deviceHeight={deviceHeight}
|
||||
deviceWidth={deviceWidth}
|
||||
metadata={metadata}
|
||||
postId={postId}
|
||||
textStyles={textStyles}
|
||||
theme={theme}
|
||||
onHashtagPress={onHashtagPress}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
renderAppEmbeds = () => {
|
||||
const {
|
||||
postId,
|
||||
postProps,
|
||||
baseTextStyle,
|
||||
blockStyles,
|
||||
deviceHeight,
|
||||
deviceWidth,
|
||||
onPermalinkPress,
|
||||
textStyles,
|
||||
theme,
|
||||
} = this.props;
|
||||
const {app_bindings} = postProps;
|
||||
|
||||
if (app_bindings && app_bindings.length) {
|
||||
return (
|
||||
<EmbeddedBindings
|
||||
embeds={app_bindings}
|
||||
baseTextStyle={baseTextStyle}
|
||||
blockStyles={blockStyles}
|
||||
deviceHeight={deviceHeight}
|
||||
deviceWidth={deviceWidth}
|
||||
postId={postId}
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
theme={theme}
|
||||
textStyles={textStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
renderOpenGraph = (isYouTube, isImage) => {
|
||||
const {isReplyPost, link, metadata, openGraphData, postId, showLinkPreviews, theme, appsEnabled} = this.props;
|
||||
|
||||
if (isYouTube || (isImage && !openGraphData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachments = this.renderMessageAttachment();
|
||||
if (attachments) {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
if (appsEnabled) {
|
||||
const appEmbeds = this.renderAppEmbeds();
|
||||
if (appEmbeds) {
|
||||
return appEmbeds;
|
||||
}
|
||||
}
|
||||
|
||||
if (!openGraphData || !showLinkPreviews) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (link) {
|
||||
if (!PostAttachmentOpenGraph) {
|
||||
PostAttachmentOpenGraph = require('app/components/post_attachment_opengraph').default;
|
||||
}
|
||||
|
||||
return (
|
||||
<PostAttachmentOpenGraph
|
||||
isReplyPost={isReplyPost}
|
||||
link={link}
|
||||
openGraphData={openGraphData}
|
||||
postId={postId}
|
||||
imagesMetadata={metadata.images}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
renderYouTubeVideo = (link) => {
|
||||
const videoId = getYouTubeVideoId(link);
|
||||
const dimensions = calculateDimensions(
|
||||
MAX_YOUTUBE_IMAGE_HEIGHT,
|
||||
MAX_YOUTUBE_IMAGE_WIDTH,
|
||||
getViewPortWidth(this.props.isReplyPost, this.hasPermanentSidebar()),
|
||||
);
|
||||
|
||||
let imgUrl;
|
||||
if (this.props.metadata?.images) {
|
||||
imgUrl = Object.keys(this.props.metadata.images)[0];
|
||||
}
|
||||
|
||||
if (!imgUrl) {
|
||||
// Fallback to default YouTube thumbnail if available
|
||||
imgUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
style={[styles.imageContainer, {height: dimensions.height}]}
|
||||
onPress={this.playYouTubeVideo}
|
||||
type={'opacity'}
|
||||
>
|
||||
<ProgressiveImage
|
||||
isBackgroundImage={true}
|
||||
imageUri={imgUrl}
|
||||
style={[styles.image, dimensions]}
|
||||
resizeMode='cover'
|
||||
onError={this.handleLinkLoadError}
|
||||
>
|
||||
<TouchableWithFeedback
|
||||
style={styles.playButton}
|
||||
onPress={this.playYouTubeVideo}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Image
|
||||
source={require('@assets/images/icons/youtube-play-icon.png')}
|
||||
onPress={this.playYouTubeVideo}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</ProgressiveImage>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
setImageSize = (uri, originalWidth, originalHeight) => {
|
||||
if (!this.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!originalWidth && !originalHeight) {
|
||||
this.setState({linkLoadError: true});
|
||||
return;
|
||||
}
|
||||
|
||||
const {isReplyPost, link} = this.props;
|
||||
const viewPortWidth = getViewPortWidth(isReplyPost, this.hasPermanentSidebar());
|
||||
|
||||
let dimensions;
|
||||
if (isYoutubeLink(link)) {
|
||||
dimensions = calculateDimensions(MAX_YOUTUBE_IMAGE_HEIGHT, MAX_YOUTUBE_IMAGE_WIDTH, viewPortWidth);
|
||||
} else {
|
||||
dimensions = calculateDimensions(originalHeight, originalWidth, viewPortWidth);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
...dimensions,
|
||||
originalHeight,
|
||||
originalWidth,
|
||||
linkLoaded: true,
|
||||
uri,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
let {link} = this.props;
|
||||
const {openGraphData, postProps, expandedLink, appsEnabled} = this.props;
|
||||
const {linkLoadError} = this.state;
|
||||
if (expandedLink) {
|
||||
link = expandedLink;
|
||||
}
|
||||
|
||||
const {attachments, app_bindings} = postProps;
|
||||
|
||||
if (!link && !attachments && !(appsEnabled && app_bindings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isYouTube = isYoutubeLink(link);
|
||||
const isImage = this.isImage(link);
|
||||
const isOpenGraph = Boolean(openGraphData);
|
||||
|
||||
if (((isImage && !isOpenGraph) || isYouTube) && !linkLoadError) {
|
||||
const embed = this.generateToggleableEmbed(isImage, isYouTube);
|
||||
if (embed) {
|
||||
return embed;
|
||||
}
|
||||
}
|
||||
|
||||
return this.renderOpenGraph(isYouTube, isImage && !linkLoadError);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
imageContainer: {
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: 6,
|
||||
marginTop: 10,
|
||||
},
|
||||
image: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 3,
|
||||
justifyContent: 'center',
|
||||
marginVertical: 1,
|
||||
},
|
||||
playButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import * as Utils from '@utils/url';
|
||||
import PostBodyAdditionalContent from './post_body_additional_content.js';
|
||||
|
||||
describe('PostBodyAdditionalContent', () => {
|
||||
const baseProps = {
|
||||
actions: {
|
||||
getRedirectLocation: jest.fn(),
|
||||
},
|
||||
link: 'http://short.ened/123',
|
||||
deviceHeight: 100,
|
||||
deviceWidth: 100,
|
||||
message: 'message',
|
||||
postId: 'post-id',
|
||||
postProps: {},
|
||||
showLinkPreviews: false,
|
||||
theme: Preferences.THEMES.default,
|
||||
appsEnabled: false,
|
||||
};
|
||||
|
||||
test('should call getRedirectLocation only if expandedLink has not been set', () => {
|
||||
const wrapper = shallowWithIntl(<PostBodyAdditionalContent {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
expect(baseProps.actions.getRedirectLocation).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.setProps({expandedLink: 'http://expanded.com/123'});
|
||||
instance.load();
|
||||
expect(baseProps.actions.getRedirectLocation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should call getRedirectLocation if expandedLink is set but link changed', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
expandedLink: 'http://expanded.com/123',
|
||||
};
|
||||
const wrapper = shallowWithIntl(<PostBodyAdditionalContent {...props}/>);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
expect(baseProps.actions.getRedirectLocation).toHaveBeenCalledTimes(0);
|
||||
|
||||
wrapper.setProps({link: `${baseProps.link}/456`});
|
||||
instance.load();
|
||||
expect(baseProps.actions.getRedirectLocation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('getImageUrl should return passed URL if content is an image', () => {
|
||||
const wrapper = shallowWithIntl(<PostBodyAdditionalContent {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
instance.isImage = jest.fn().mockReturnValueOnce(true);
|
||||
|
||||
const url = 'https://test.url';
|
||||
const imageUrl = instance.getImageUrl(url);
|
||||
expect(imageUrl).toEqual(url);
|
||||
});
|
||||
|
||||
test('getImageUrl should return first metadata image URL if content is a YouTube link', () => {
|
||||
const url1 = 'https://test.url1';
|
||||
const url2 = 'https://test.url2';
|
||||
const props = {
|
||||
...baseProps,
|
||||
metadata: {
|
||||
images: {
|
||||
[url1]: 'URL 1',
|
||||
[url2]: 'URL 2',
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowWithIntl(<PostBodyAdditionalContent {...props}/>);
|
||||
const instance = wrapper.instance();
|
||||
instance.isImage = jest.fn().mockReturnValueOnce(false);
|
||||
Utils.isYoutubeLink = jest.fn().mockReturnValueOnce(true); // eslint-disable-line no-import-assign
|
||||
|
||||
const imageUrl = instance.getImageUrl();
|
||||
expect(imageUrl).toEqual(url1);
|
||||
});
|
||||
|
||||
test('getImageUrl should return default URL if content is a YouTube link and there is no image metadata', () => {
|
||||
const wrapper = shallowWithIntl(<PostBodyAdditionalContent {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
instance.isImage = jest.fn().mockReturnValueOnce(false);
|
||||
Utils.isYoutubeLink = jest.fn().mockReturnValueOnce(true); // eslint-disable-line no-import-assign
|
||||
Utils.getYouTubeVideoId = jest.fn().mockReturnValueOnce('videoId'); // eslint-disable-line no-import-assign
|
||||
|
||||
const imageUrl = instance.getImageUrl();
|
||||
expect(imageUrl).toEqual('https://i.ytimg.com/vi/videoId/hqdefault.jpg');
|
||||
});
|
||||
|
||||
test('getImageUrl should return undefined if content is not an image nor a YouTube link', () => {
|
||||
const wrapper = shallowWithIntl(<PostBodyAdditionalContent {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
instance.isImage = jest.fn().mockReturnValueOnce(false);
|
||||
Utils.isYoutubeLink = jest.fn().mockReturnValueOnce(false); // eslint-disable-line no-import-assign
|
||||
|
||||
const imageUrl = instance.getImageUrl();
|
||||
expect(imageUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -194,7 +194,7 @@ export default class DraftInput extends PureComponent {
|
|||
this.setState(nextState, callback);
|
||||
}
|
||||
|
||||
EventEmitter.emit('scroll-to-bottom');
|
||||
EventEmitter.emit('scroll-to-bottom', EphemeralStore.getNavigationTopComponentId());
|
||||
};
|
||||
|
||||
handleHardwareEnterPress = (keyEvent) => {
|
||||
|
|
|
|||
|
|
@ -3,20 +3,18 @@
|
|||
|
||||
import React from 'react';
|
||||
import {Alert, Platform, StatusBar} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import CameraQuickAction from './index';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchCamera: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
|
||||
}));
|
||||
|
||||
describe('CameraButton', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
testID: 'post_draft.quick_actions.camera_action',
|
||||
fileCount: 0,
|
||||
|
|
@ -27,7 +25,7 @@ describe('CameraButton', () => {
|
|||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<CameraQuickAction {...baseProps}/>);
|
||||
const wrapper = shallowWithIntl(<CameraQuickAction {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -36,9 +34,8 @@ describe('CameraButton', () => {
|
|||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<CameraQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasCameraPermission();
|
||||
|
|
@ -52,9 +49,8 @@ describe('CameraButton', () => {
|
|||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<CameraQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasCameraPermission();
|
||||
|
|
@ -81,9 +77,8 @@ describe('CameraButton', () => {
|
|||
const request = jest.spyOn(Permissions, 'request');
|
||||
request.mockReturnValue(Permissions.RESULTS.GRANTED);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<CameraQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
|
|
@ -102,9 +97,8 @@ describe('CameraButton', () => {
|
|||
});
|
||||
|
||||
test('should re-enable StatusBar after ImagePicker launchCamera finishes', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<CameraQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
|
|
|
|||
|
|
@ -3,17 +3,14 @@
|
|||
|
||||
import React from 'react';
|
||||
import {Alert, Platform} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import FileQuickAction from './index';
|
||||
|
||||
jest.mock('react-intl');
|
||||
|
||||
describe('FileQuickAction', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
testID: 'post_draft.quick_actions.file_action',
|
||||
fileCount: 0,
|
||||
|
|
@ -32,7 +29,7 @@ describe('FileQuickAction', () => {
|
|||
});
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<FileQuickAction {...baseProps}/>);
|
||||
const wrapper = shallowWithIntl(<FileQuickAction {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -41,9 +38,8 @@ describe('FileQuickAction', () => {
|
|||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<FileQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasStoragePermission();
|
||||
|
|
@ -57,9 +53,8 @@ describe('FileQuickAction', () => {
|
|||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<FileQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasStoragePermission();
|
||||
|
|
@ -70,9 +65,8 @@ describe('FileQuickAction', () => {
|
|||
});
|
||||
|
||||
test('hasStoragePermission returns true when permission has been granted', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<FileQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
const check = jest.spyOn(Permissions, 'check');
|
||||
|
|
|
|||
|
|
@ -3,20 +3,18 @@
|
|||
|
||||
import React from 'react';
|
||||
import {Alert, Platform, StatusBar} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import ImageQuickAction from './index';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchImageLibrary: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
|
||||
}));
|
||||
|
||||
describe('ImageQuickAction', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
testID: 'post_draft.quick_actions.image_action',
|
||||
fileCount: 0,
|
||||
|
|
@ -27,7 +25,7 @@ describe('ImageQuickAction', () => {
|
|||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<ImageQuickAction {...baseProps}/>);
|
||||
const wrapper = shallowWithIntl(<ImageQuickAction {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -36,9 +34,8 @@ describe('ImageQuickAction', () => {
|
|||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ImageQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasPhotoPermission();
|
||||
|
|
@ -52,9 +49,8 @@ describe('ImageQuickAction', () => {
|
|||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ImageQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasPhotoPermission();
|
||||
|
|
@ -81,9 +77,8 @@ describe('ImageQuickAction', () => {
|
|||
const request = jest.spyOn(Permissions, 'request');
|
||||
request.mockReturnValue(Permissions.RESULTS.GRANTED);
|
||||
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ImageQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
|
|
@ -102,9 +97,8 @@ describe('ImageQuickAction', () => {
|
|||
});
|
||||
|
||||
test('should re-enable StatusBar after ImagePicker launchImageLibrary finishes', async () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = shallowWithIntl(
|
||||
<ImageQuickAction {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ exports[`PostDraft ReadOnly should match snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
/>
|
||||
<FormattedText
|
||||
<InjectIntl(FormattedText)
|
||||
defaultMessage="This channel is read-only."
|
||||
id="mobile.create_post.read_only"
|
||||
style={
|
||||
|
|
|
|||
|
|
@ -29,17 +29,14 @@ exports[`UploadItem downloadAndUploadFile should match, full snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
>
|
||||
<FileAttachmentIcon
|
||||
<FileIcon
|
||||
backgroundColor="rgba(61,60,64,0.08)"
|
||||
defaultImage={false}
|
||||
failed={false}
|
||||
file={
|
||||
Object {
|
||||
"loading": false,
|
||||
}
|
||||
}
|
||||
iconSize={48}
|
||||
smallImage={false}
|
||||
iconSize={60}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import RNFetchBlob from 'rn-fetch-blob';
|
|||
import {Client4} from '@client/rest';
|
||||
|
||||
import mattermostBucket from 'app/mattermost_bucket';
|
||||
import FileAttachmentImage from '@components/file_attachment_list/file_attachment_image';
|
||||
import FileAttachmentIcon from '@components/file_attachment_list/file_attachment_icon';
|
||||
import ImageFile from '@components/post_list/post/body/files/image_file';
|
||||
import FileIcon from '@components//post_list/post/body/files/file_icon';
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import {buildFileUploadData, encodeHeaderURIStringToUTF8, isImage} from '@utils/file';
|
||||
import {emptyFunction} from '@utils/general';
|
||||
|
|
@ -170,7 +170,7 @@ export default class UploadItem extends PureComponent {
|
|||
filePreviewComponent = (
|
||||
<TouchableOpacity onPress={this.handlePress}>
|
||||
<View style={styles.filePreview}>
|
||||
<FileAttachmentImage
|
||||
<ImageFile
|
||||
file={file}
|
||||
theme={theme}
|
||||
resizeMode='cover'
|
||||
|
|
@ -182,10 +182,11 @@ export default class UploadItem extends PureComponent {
|
|||
filePreviewComponent = (
|
||||
<TouchableOpacity onPress={this.handlePress}>
|
||||
<View style={styles.filePreview}>
|
||||
<FileAttachmentIcon
|
||||
<FileIcon
|
||||
backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)}
|
||||
iconSize={60}
|
||||
file={file}
|
||||
theme={theme}
|
||||
backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue