Post List & post components refactored (#5409) (#5417)

This commit is contained in:
Mattermost Build 2021-06-03 20:59:42 +02:00 committed by GitHub
parent cf88a2ae8f
commit 0d83ac4c93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
362 changed files with 9954 additions and 17522 deletions

View file

@ -39,6 +39,10 @@ import com.facebook.react.module.model.ReactModuleInfoProvider;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.facebook.soloader.SoLoader;
import com.facebook.react.bridge.JSIModulePackage;
import com.swmansion.reanimated.ReanimatedJSIModulePackage;
public class MainApplication extends NavigationApplication implements INotificationsApplication, INotificationsDrawerApplication {
public static MainApplication instance;
@ -111,6 +115,11 @@ private final ReactNativeHost mReactNativeHost =
protected String getJSMainModuleName() {
return "index";
}
@Override
protected JSIModulePackage getJSIModulePackage() {
return new ReanimatedJSIModulePackage();
}
};
@Override

View file

@ -10,6 +10,7 @@ import {getTheme} from '@mm-redux/selectors/entities/preferences';
import EventEmmiter from '@mm-redux/utils/event_emitter';
import {DeviceTypes, NavigationTypes} from '@constants';
import {CHANNEL} from '@constants/screen';
import EphemeralStore from '@store/ephemeral_store';
import Store from '@store/store';
@ -33,8 +34,8 @@ export function resetToChannel(passProps = {}) {
const stack = {
children: [{
component: {
id: NavigationTypes.CHANNEL_SCREEN,
name: NavigationTypes.CHANNEL_SCREEN,
id: CHANNEL,
name: CHANNEL,
passProps,
options: {
layout: {
@ -363,17 +364,20 @@ export async function dismissModal(options = {}) {
}
}
export async function dismissAllModals(options = {}) {
export async function dismissAllModals(options) {
if (!EphemeralStore.hasModalsOpened()) {
return;
}
try {
if (Platform.OS === 'ios') {
const modals = [...EphemeralStore.navigationModalStack];
for await (const modal of modals) {
await Navigation.dismissModal(modal, options);
EphemeralStore.removeNavigationModal(modal);
}
} else {
await Navigation.dismissAllModals(options);
EphemeralStore.clearNavigationModals();
} catch (error) {
// RNN returns a promise rejection if there are no modals to
// dismiss. We'll do nothing in this case.
}
}
@ -441,7 +445,7 @@ export function closeMainSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
Navigation.mergeOptions(CHANNEL, {
sideMenu: {
left: {visible: false},
},
@ -453,7 +457,7 @@ export function enableMainSideMenu(enabled, visible = true) {
return;
}
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
Navigation.mergeOptions(CHANNEL, {
sideMenu: {
left: {enabled, visible},
},
@ -466,7 +470,7 @@ export function openSettingsSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
Navigation.mergeOptions(CHANNEL, {
sideMenu: {
right: {visible: true},
},
@ -479,7 +483,7 @@ export function closeSettingsSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
Navigation.mergeOptions(CHANNEL, {
sideMenu: {
right: {visible: false},
},

View file

@ -17,17 +17,23 @@ import Store from '@store/store';
import {NavigationTypes} from '@constants';
jest.unmock('@actions/navigation');
jest.mock('@store/ephemeral_store', () => ({
getNavigationTopComponentId: jest.fn(),
clearNavigationComponents: jest.fn(),
addNavigationModal: jest.fn(),
hasModalsOpened: jest.fn().mockReturnValue(true),
}));
const mockStore = configureMockStore([thunk]);
const store = mockStore(intitialState);
Store.redux = store;
// Mock EphemeralStore add/remove modal
const add = EphemeralStore.addNavigationModal;
const remove = EphemeralStore.removeNavigationModal;
EphemeralStore.removeNavigationModal = (componentId) => {
remove(componentId);
EphemeralStore.removeNavigationComponentId(componentId);
};
EphemeralStore.addNavigationModal = (componentId) => {
add(componentId);
EphemeralStore.addNavigationComponentId(componentId);
};
describe('@actions/navigation', () => {
const topComponentId = 'top-component-id';
const name = 'name';
@ -39,7 +45,16 @@ describe('@actions/navigation', () => {
const options = {
testOption: 'test',
};
EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
beforeEach(() => {
EphemeralStore.clearNavigationComponents();
EphemeralStore.clearNavigationModals();
// mock that we have a root screen
EphemeralStore.addNavigationComponentId(topComponentId);
});
// EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
test('resetToChannel should call Navigation.setRoot', () => {
const setRoot = jest.spyOn(Navigation, 'setRoot');
@ -425,15 +440,20 @@ describe('@actions/navigation', () => {
test('dismissModal should call Navigation.dismissModal', async () => {
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
NavigationActions.showModal('First', 'First Modal', passProps, options);
await NavigationActions.dismissModal(options);
expect(dismissModal).toHaveBeenCalledWith(topComponentId, options);
expect(dismissModal).toHaveBeenCalledWith('First', options);
});
test('dismissAllModals should call Navigation.dismissAllModals', async () => {
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
NavigationActions.showModal('First', 'First Modal', passProps, options);
NavigationActions.showModal('Second', 'Second Modal', passProps, options);
await NavigationActions.dismissAllModals(options);
expect(dismissAllModals).toHaveBeenCalledWith(options);
expect(dismissModal).toHaveBeenCalledTimes(2);
});
test('mergeNavigationOptions should call Navigation.mergeOptions', () => {
@ -493,12 +513,15 @@ describe('@actions/navigation', () => {
});
test('dismissAllModalsAndPopToRoot should call Navigation.dismissAllModals, Navigation.popToRoot, and emit event', async () => {
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
const popToRoot = jest.spyOn(Navigation, 'popToRoot');
EventEmitter.emit = jest.fn();
NavigationActions.showModal('First', 'First Modal', passProps, options);
NavigationActions.showModal('Second', 'Second Modal', passProps, options);
await NavigationActions.dismissAllModalsAndPopToRoot();
expect(dismissAllModals).toHaveBeenCalled();
expect(dismissModal).toHaveBeenCalledTimes(2);
expect(popToRoot).toHaveBeenCalledWith(topComponentId);
expect(EventEmitter.emit).toHaveBeenCalledWith(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT);
});

View file

@ -247,7 +247,7 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler, i
// Fallback to API response error, if any.
if (teamError) {
if (errorHandler) {
errorHandler();
errorHandler(intl);
}
return {error: teamError};
}

View file

@ -4,14 +4,15 @@
import {intlShape} from 'react-intl';
import {Keyboard} from 'react-native';
import {showModalOverCurrentContext} from '@actions/navigation';
import {dismissAllModals, showModalOverCurrentContext} from '@actions/navigation';
import {loadChannelsByTeamName} from '@actions/views/channel';
import {selectFocusedPostId} from '@mm-redux/actions/posts';
import type {DispatchFunc} from '@mm-redux/types/actions';
import {permalinkBadTeam} from '@utils/general';
import {changeOpacity} from '@utils/theme';
export let showingPermalink = false;
import type {DispatchFunc} from '@mm-redux/types/actions';
let showingPermalink = false;
export function showPermalink(intl: typeof intlShape, teamName: string, postId: string, openAsPermalink = true) {
return async (dispatch: DispatchFunc) => {
@ -20,27 +21,26 @@ export function showPermalink(intl: typeof intlShape, teamName: string, postId:
if (!loadTeam.error) {
Keyboard.dismiss();
dispatch(selectFocusedPostId(postId));
if (!showingPermalink) {
const screen = 'Permalink';
const passProps = {
isPermalink: openAsPermalink,
onClose: () => {
dispatch(closePermalink());
},
teamName,
};
const options = {
layout: {
componentBackgroundColor: changeOpacity('#000', 0.2),
},
};
showingPermalink = true;
showModalOverCurrentContext(screen, passProps, options);
if (showingPermalink) {
await dismissAllModals();
}
const screen = 'Permalink';
const passProps = {
isPermalink: openAsPermalink,
teamName,
};
const options = {
layout: {
componentBackgroundColor: changeOpacity('#000', 0.2),
},
};
showingPermalink = true;
showModalOverCurrentContext(screen, passProps, options);
}
return {};
};
}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {loadChannelsForTeam} from '@actions/views/channel';
import {loadChannelsForTeam, setChannelRetryFailed} from '@actions/views/channel';
import {getPostsSince} from '@actions/views/post';
import {loadMe} from '@actions/views/user';
import {WebsocketEvents} from '@constants';
@ -135,7 +135,10 @@ export function doReconnect(now: number) {
const {lastDisconnectAt} = state.websocket;
const actions: Array<GenericAction> = [];
dispatch(wsConnected(now));
dispatch(batchActions([
wsConnected(now),
setChannelRetryFailed(false),
], 'BATCH_WS_SUCCESS'));
try {
const {data: me}: any = await dispatch(loadMe(null, null, true));

View file

@ -9,7 +9,7 @@ import {Server, WebSocket as MockWebSocket} from 'mock-socket';
import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store';
import {GeneralTypes, UserTypes} from '@mm-redux/action_types';
import {UserTypes} from '@mm-redux/action_types';
import {notVisibleUsersActions} from '@mm-redux/actions/helpers';
import {Client4} from '@client/rest';
import {General, Posts, RequestStatus} from '@mm-redux/constants';
@ -176,7 +176,7 @@ describe('Actions.Websocket doReconnect', () => {
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
'BATCH_WS_SUCCESS',
];
const expectedMissingActions = [
'BATCH_WS_RECONNECT',
@ -215,7 +215,7 @@ describe('Actions.Websocket doReconnect', () => {
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
'BATCH_WS_SUCCESS',
'BATCH_WS_RECONNECT',
];
const expectedMissingActions = [
@ -260,7 +260,7 @@ describe('Actions.Websocket doReconnect', () => {
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
'BATCH_WS_SUCCESS',
];
const expectedMissingActions = [
'BATCH_WS_RECONNECT',
@ -304,7 +304,7 @@ describe('Actions.Websocket doReconnect', () => {
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
'BATCH_WS_SUCCESS',
'BATCH_WS_RECONNECT',
];
const expectedMissingActions = [
@ -337,7 +337,7 @@ describe('Actions.Websocket doReconnect', () => {
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
'BATCH_WS_SUCCESS',
'BATCH_WS_LEAVE_TEAM',
'BATCH_WS_RECONNECT',
];

View file

@ -16,14 +16,14 @@ import mattermostManaged from 'app/mattermost_managed';
export default class AtMention extends React.PureComponent {
static propTypes = {
isSearchResult: PropTypes.bool,
mentionKeys: PropTypes.array.isRequired,
mentionKeys: PropTypes.array,
mentionName: PropTypes.string.isRequired,
mentionStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
onPostPress: PropTypes.func,
textStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
teammateNameDisplay: PropTypes.string,
theme: PropTypes.object.isRequired,
usersByUsername: PropTypes.object.isRequired,
usersByUsername: PropTypes.object,
groupsByName: PropTypes.object,
};

View file

@ -3,13 +3,10 @@
import {connect} from 'react-redux';
import {getUsersByUsername} from '@mm-redux/selectors/entities/users';
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import {getAllGroupsForReferenceByName} from '@mm-redux/selectors/entities/groups';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
import {getUsersByUsername} from '@mm-redux/selectors/entities/users';
import AtMention from './at_mention';

View file

@ -2,25 +2,21 @@
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Permissions from 'react-native-permissions';
import {Alert, StatusBar} from 'react-native';
import Permissions from 'react-native-permissions';
import Preferences from '@mm-redux/constants/preferences';
import {VALID_MIME_TYPES} from 'app/screens/edit_profile/edit_profile';
import {VALID_MIME_TYPES} from '@screens/edit_profile/edit_profile';
import {shallowWithIntl} from 'test/intl-test-helper';
import AttachmentButton from './index';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
launchImageLibrary: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
}));
describe('AttachmentButton', () => {
const formatMessage = jest.fn();
const baseProps = {
theme: Preferences.THEMES.default,
maxFileSize: 10,
@ -28,7 +24,7 @@ describe('AttachmentButton', () => {
};
test('should match snapshot', () => {
const wrapper = shallow(<AttachmentButton {...baseProps}/>);
const wrapper = shallowWithIntl(<AttachmentButton {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
@ -40,7 +36,7 @@ describe('AttachmentButton', () => {
onShowUnsupportedMimeTypeWarning: jest.fn(),
};
const wrapper = shallow(<AttachmentButton {...props}/>);
const wrapper = shallowWithIntl(<AttachmentButton {...props}/>);
const file = {
type: 'image/gif',
@ -59,7 +55,7 @@ describe('AttachmentButton', () => {
onShowUnsupportedMimeTypeWarning: jest.fn(),
};
const wrapper = shallow(<AttachmentButton {...props}/>);
const wrapper = shallowWithIntl(<AttachmentButton {...props}/>);
const file = {
fileSize: 10,
@ -77,9 +73,8 @@ describe('AttachmentButton', () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.DENIED);
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
const wrapper = shallow(
const wrapper = shallowWithIntl(
<AttachmentButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
@ -93,9 +88,8 @@ describe('AttachmentButton', () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
jest.spyOn(Alert, 'alert').mockReturnValue(true);
const wrapper = shallow(
const wrapper = shallowWithIntl(
<AttachmentButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
@ -106,9 +100,8 @@ describe('AttachmentButton', () => {
});
test('should re-enable StatusBar after ImagePicker launchCamera finishes', async () => {
const wrapper = shallow(
const wrapper = shallowWithIntl(
<AttachmentButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const instance = wrapper.instance();
@ -120,9 +113,8 @@ describe('AttachmentButton', () => {
});
test('should re-enable StatusBar after ImagePicker launchImageLibrary finishes', async () => {
const wrapper = shallow(
const wrapper = shallowWithIntl(
<AttachmentButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const instance = wrapper.instance();

View file

@ -6,8 +6,8 @@ import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
import {Command, AutocompleteSuggestion} from '@mm-redux/types/integrations';
import Store from '@store/store';
import {intl} from 'test/intl-test-helper';
import {
thunk,
@ -86,7 +86,10 @@ describe('components/autocomplete/slash_suggestion', () => {
...baseProps,
};
const wrapper = shallow(<SlashSuggestion {...props}/>);
const wrapper = shallow(
<SlashSuggestion {...props}/>,
{context: {intl}},
);
const dataSource: AutocompleteSuggestion[] = [
{
@ -117,7 +120,10 @@ describe('components/autocomplete/slash_suggestion', () => {
commands: [command],
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
const wrapper = shallow<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{context: {intl}},
);
wrapper.setProps({value: '/the'});
expect(wrapper.state('dataSource')).toEqual([
@ -137,7 +143,10 @@ describe('components/autocomplete/slash_suggestion', () => {
commands: [],
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
const wrapper = shallow<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{context: {intl}},
);
wrapper.setProps({value: '/ji'});
expect(wrapper.state('dataSource')).toEqual([
@ -156,7 +165,10 @@ describe('components/autocomplete/slash_suggestion', () => {
...baseProps,
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
const wrapper = shallow<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{context: {intl}},
);
wrapper.setProps({value: '/'});
expect(wrapper.state('dataSource')).toEqual([
@ -207,7 +219,10 @@ describe('components/autocomplete/slash_suggestion', () => {
...baseProps,
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
const wrapper = shallow<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{context: {intl}},
);
wrapper.setProps({value: '/jira i', suggestions: []});
const expected: AutocompleteSuggestion[] = [
@ -232,7 +247,10 @@ describe('components/autocomplete/slash_suggestion', () => {
appsEnabled: false,
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
const wrapper = shallow<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{context: {intl}},
);
wrapper.setProps({value: '/', suggestions: []});
expect(wrapper.state('dataSource')).toEqual([

View file

@ -2,15 +2,13 @@
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Text} from 'react-native';
import {alertErrorWithFallback} from '@utils/general';
import {shallowWithIntl} from 'test/intl-test-helper';
import ChannelLink from './channel_link';
jest.mock('react-intl');
jest.mock('@utils/general', () => {
const general = jest.requireActual('../../utils/general');
return {
@ -20,7 +18,6 @@ jest.mock('@utils/general', () => {
});
describe('ChannelLink', () => {
const formatMessage = jest.fn();
const channelsByName = {
firstChannel: {id: 'channel_id_1', name: 'firstChannel', display_name: 'First Channel', team_id: 'current_team_id'},
secondChannel: {id: 'channel_id_2', name: 'secondChannel', display_name: 'Second Channel', team_id: 'current_team_id'},
@ -40,9 +37,8 @@ describe('ChannelLink', () => {
};
test('should match snapshot', () => {
const wrapper = shallow(
const wrapper = shallowWithIntl(
<ChannelLink {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
@ -74,9 +70,8 @@ describe('ChannelLink', () => {
});
test('should call props.actions and onChannelLinkPress on handlePress', async () => {
const wrapper = shallow(
const wrapper = shallowWithIntl(
<ChannelLink {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const channel = channelsByName.firstChannel;
@ -106,16 +101,17 @@ describe('ChannelLink', () => {
channelName: newChannelName,
actions: {...baseProps.actions, joinChannel},
};
const intl = {formatMessage};
const joinFailedMessage = {
id: 'mobile.join_channel.error',
defaultMessage: 'We couldn\'t join the channel {displayName}. Please check your connection and try again.',
};
const wrapper = shallow(
const wrapper = shallowWithIntl(
<ChannelLink {...newProps}/>,
{context: {intl}},
);
const {intl} = wrapper.context();
await wrapper.instance().handlePress();
expect(newProps.actions.joinChannel).toHaveBeenCalledTimes(1);
expect(newProps.actions.joinChannel).toBeCalledWith('current_user_id', 'current_team_id', null, newChannelName);

View file

@ -66,7 +66,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
size="small"
/>
</View>
<FormattedText
<InjectIntl(FormattedText)
defaultMessage="Still trying to load your content..."
id="mobile.channel_loader.still_loading"
style={

View file

@ -1,393 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {intlShape} from 'react-intl';
import {Posts} from '@mm-redux/constants';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import {t} from 'app/utils/i18n';
import Markdown from 'app/components/markdown';
import LastUsers from './last_users';
const {
JOIN_CHANNEL, ADD_TO_CHANNEL, REMOVE_FROM_CHANNEL, LEAVE_CHANNEL,
JOIN_TEAM, ADD_TO_TEAM, REMOVE_FROM_TEAM, LEAVE_TEAM,
} = Posts.POST_TYPES;
const postTypeMessage = {
[JOIN_CHANNEL]: {
one: {
id: t('combined_system_message.joined_channel.one'),
defaultMessage: '{firstUser} **joined the channel**.',
},
one_you: {
id: t('combined_system_message.joined_channel.one_you'),
defaultMessage: 'You **joined the channel**.',
},
two: {
id: t('combined_system_message.joined_channel.two'),
defaultMessage: '{firstUser} and {secondUser} **joined the channel**.',
},
many_expanded: {
id: t('combined_system_message.joined_channel.many_expanded'),
defaultMessage: '{users} and {lastUser} **joined the channel**.',
},
},
[ADD_TO_CHANNEL]: {
one: {
id: t('combined_system_message.added_to_channel.one'),
defaultMessage: '{firstUser} **added to the channel** by {actor}.',
},
one_you: {
id: t('combined_system_message.added_to_channel.one_you'),
defaultMessage: 'You were **added to the channel** by {actor}.',
},
two: {
id: t('combined_system_message.added_to_channel.two'),
defaultMessage: '{firstUser} and {secondUser} **added to the channel** by {actor}.',
},
many_expanded: {
id: t('combined_system_message.added_to_channel.many_expanded'),
defaultMessage: '{users} and {lastUser} were **added to the channel** by {actor}.',
},
},
[REMOVE_FROM_CHANNEL]: {
one: {
id: t('combined_system_message.removed_from_channel.one'),
defaultMessage: '{firstUser} was **removed from the channel**.',
},
one_you: {
id: t('combined_system_message.removed_from_channel.one_you'),
defaultMessage: 'You were **removed from the channel**.',
},
two: {
id: t('combined_system_message.removed_from_channel.two'),
defaultMessage: '{firstUser} and {secondUser} were **removed from the channel**.',
},
many_expanded: {
id: t('combined_system_message.removed_from_channel.many_expanded'),
defaultMessage: '{users} and {lastUser} were **removed from the channel**.',
},
},
[LEAVE_CHANNEL]: {
one: {
id: t('combined_system_message.left_channel.one'),
defaultMessage: '{firstUser} **left the channel**.',
},
one_you: {
id: t('combined_system_message.left_channel.one_you'),
defaultMessage: 'You **left the channel**.',
},
two: {
id: t('combined_system_message.left_channel.two'),
defaultMessage: '{firstUser} and {secondUser} **left the channel**.',
},
many_expanded: {
id: t('combined_system_message.left_channel.many_expanded'),
defaultMessage: '{users} and {lastUser} **left the channel**.',
},
},
[JOIN_TEAM]: {
one: {
id: t('combined_system_message.joined_team.one'),
defaultMessage: '{firstUser} **joined the team**.',
},
one_you: {
id: t('combined_system_message.joined_team.one_you'),
defaultMessage: 'You **joined the team**.',
},
two: {
id: t('combined_system_message.joined_team.two'),
defaultMessage: '{firstUser} and {secondUser} **joined the team**.',
},
many_expanded: {
id: t('combined_system_message.joined_team.many_expanded'),
defaultMessage: '{users} and {lastUser} **joined the team**.',
},
},
[ADD_TO_TEAM]: {
one: {
id: t('combined_system_message.added_to_team.one'),
defaultMessage: '{firstUser} **added to the team** by {actor}.',
},
one_you: {
id: t('combined_system_message.added_to_team.one_you'),
defaultMessage: 'You were **added to the team** by {actor}.',
},
two: {
id: t('combined_system_message.added_to_team.two'),
defaultMessage: '{firstUser} and {secondUser} **added to the team** by {actor}.',
},
many_expanded: {
id: t('combined_system_message.added_to_team.many_expanded'),
defaultMessage: '{users} and {lastUser} were **added to the team** by {actor}.',
},
},
[REMOVE_FROM_TEAM]: {
one: {
id: t('combined_system_message.removed_from_team.one'),
defaultMessage: '{firstUser} was **removed from the team**.',
},
one_you: {
id: t('combined_system_message.removed_from_team.one_you'),
defaultMessage: 'You were **removed from the team**.',
},
two: {
id: t('combined_system_message.removed_from_team.two'),
defaultMessage: '{firstUser} and {secondUser} were **removed from the team**.',
},
many_expanded: {
id: t('combined_system_message.removed_from_team.many_expanded'),
defaultMessage: '{users} and {lastUser} were **removed from the team**.',
},
},
[LEAVE_TEAM]: {
one: {
id: t('combined_system_message.left_team.one'),
defaultMessage: '{firstUser} **left the team**.',
},
one_you: {
id: t('combined_system_message.left_team.one_you'),
defaultMessage: 'You **left the team**.',
},
two: {
id: t('combined_system_message.left_team.two'),
defaultMessage: '{firstUser} and {secondUser} **left the team**.',
},
many_expanded: {
id: t('combined_system_message.left_team.many_expanded'),
defaultMessage: '{users} and {lastUser} **left the team**.',
},
},
};
export default class CombinedSystemMessage extends React.PureComponent {
static propTypes = {
actions: PropTypes.shape({
getMissingProfilesByIds: PropTypes.func.isRequired,
getMissingProfilesByUsernames: PropTypes.func.isRequired,
}).isRequired,
allUserIds: PropTypes.array.isRequired,
allUsernames: PropTypes.array.isRequired,
currentUserId: PropTypes.string.isRequired,
currentUsername: PropTypes.string.isRequired,
messageData: PropTypes.array.isRequired,
showJoinLeave: PropTypes.bool.isRequired,
textStyles: PropTypes.object,
theme: PropTypes.object.isRequired,
userProfiles: PropTypes.array.isRequired,
};
static defaultProps = {
allUserIds: [],
allUsernames: [],
};
static contextTypes = {
intl: intlShape,
};
componentDidMount() {
this.loadUserProfiles(this.props.allUserIds, this.props.allUsernames);
}
componentDidUpdate(prevProps) {
const {allUserIds, allUsernames} = this.props;
if (allUserIds !== prevProps.allUserIds || allUsernames !== prevProps.allUsernames) {
this.loadUserProfiles(allUserIds, allUsernames);
}
}
loadUserProfiles = (allUserIds, allUsernames) => {
if (allUserIds.length > 0) {
this.props.actions.getMissingProfilesByIds(allUserIds);
}
if (allUsernames.length > 0) {
this.props.actions.getMissingProfilesByUsernames(allUsernames);
}
}
getAllUsernames = () => {
const {
allUserIds,
allUsernames,
currentUserId,
currentUsername,
userProfiles,
} = this.props;
const {formatMessage} = this.context.intl;
const usernames = userProfiles.reduce((acc, user) => {
acc[user.id] = user.username;
acc[user.username] = user.username;
return acc;
}, {});
const currentUserDisplayName = formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'});
if (allUserIds.includes(currentUserId)) {
usernames[currentUserId] = currentUserDisplayName;
} else if (allUsernames.includes(currentUsername)) {
usernames[currentUsername] = currentUserDisplayName;
}
return usernames;
}
getUsernamesByIds = (userIds = []) => {
const {currentUserId, currentUsername} = this.props;
const allUsernames = this.getAllUsernames();
const {formatMessage} = this.context.intl;
const someone = formatMessage({id: t('channel_loader.someone'), defaultMessage: 'Someone'});
const usernames = userIds.
filter((userId) => {
return userId !== currentUserId && userId !== currentUsername;
}).
map((userId) => {
return allUsernames[userId] ? `@${allUsernames[userId]}` : someone;
}).filter((username) => {
return username && username !== '';
});
if (userIds.includes(currentUserId)) {
usernames.unshift(allUsernames[currentUserId]);
} else if (userIds.includes(currentUsername)) {
usernames.unshift(allUsernames[currentUsername]);
}
return usernames;
}
renderFormattedMessage(postType, userIds, actorId, style) {
const {formatMessage} = this.context.intl;
const {
currentUserId,
currentUsername,
textStyles,
theme,
} = this.props;
const usernames = this.getUsernamesByIds(userIds);
let actor = actorId ? this.getUsernamesByIds([actorId])[0] : '';
if (actor && (actorId === currentUserId || actorId === currentUsername)) {
actor = actor.toLowerCase();
}
const firstUser = usernames[0];
const secondUser = usernames[1];
const numOthers = usernames.length - 1;
if (numOthers > 1) {
return (
<LastUsers
actor={actor}
expandedLocale={postTypeMessage[postType].many_expanded}
postType={postType}
style={style}
textStyles={textStyles}
theme={theme}
usernames={usernames}
/>
);
}
let localeHolder;
if (numOthers === 0) {
localeHolder = postTypeMessage[postType].one;
if (
(userIds[0] === currentUserId || userIds[0] === currentUsername) &&
postTypeMessage[postType].one_you
) {
localeHolder = postTypeMessage[postType].one_you;
}
} else if (numOthers === 1) {
localeHolder = postTypeMessage[postType].two;
}
const formattedMessage = formatMessage(localeHolder, {firstUser, secondUser, actor});
return (
<Markdown
baseTextStyle={style.baseText}
textStyles={textStyles}
value={formattedMessage}
/>
);
}
renderMessage(postType, userIds, actorId, style) {
return (
<React.Fragment key={postType + actorId}>
{this.renderFormattedMessage(postType, userIds, actorId, {baseText: style.baseText, linkText: style.linkText})}
</React.Fragment>
);
}
render() {
const {
currentUserId,
messageData,
theme,
} = this.props;
const style = getStyleSheet(theme);
const content = [];
const removedUserIds = [];
for (const message of messageData) {
const {
postType,
actorId,
} = message;
let userIds = message.userIds;
if (!this.props.showJoinLeave && actorId !== currentUserId) {
const affectsCurrentUser = userIds.indexOf(currentUserId) !== -1;
if (affectsCurrentUser) {
// Only show the message that the current user was added, etc
userIds = [currentUserId];
} else {
// Not something the current user did or was affected by
continue;
}
}
if (postType === REMOVE_FROM_CHANNEL) {
removedUserIds.push(...userIds);
continue;
}
content.push(this.renderMessage(postType, userIds, actorId, style));
}
if (removedUserIds.length > 0) {
const uniqueRemovedUserIds = removedUserIds.filter((id, index, arr) => arr.indexOf(id) === index);
content.push(this.renderMessage(REMOVE_FROM_CHANNEL, uniqueRemovedUserIds, currentUserId, style));
}
return (
<React.Fragment>
{content}
</React.Fragment>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
baseText: {
color: theme.centerChannelColor,
opacity: 0.6,
},
linkText: {
color: theme.linkColor,
opacity: 0.8,
},
};
});

View file

@ -1,38 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getMissingProfilesByIds, getMissingProfilesByUsernames} from '@mm-redux/actions/users';
import {Preferences} from '@mm-redux/constants';
import {getBool} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUser, makeGetProfilesByIdsAndUsernames} from '@mm-redux/selectors/entities/users';
import CombinedSystemMessage from './combined_system_message';
function makeMapStateToProps() {
const getProfilesByIdsAndUsernames = makeGetProfilesByIdsAndUsernames();
return (state, ownProps) => {
const currentUser = getCurrentUser(state) || {};
const {allUserIds, allUsernames} = ownProps;
return {
currentUserId: currentUser.id,
currentUsername: currentUser.username,
showJoinLeave: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true),
userProfiles: getProfilesByIdsAndUsernames(state, {allUserIds, allUsernames}),
};
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getMissingProfilesByIds,
getMissingProfilesByUsernames,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(CombinedSystemMessage);

View file

@ -1,168 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {Text} from 'react-native';
import {intlShape} from 'react-intl';
import {Posts} from '@mm-redux/constants';
import FormattedMarkdownText from 'app/components/formatted_markdown_text';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import {t} from 'app/utils/i18n';
const typeMessage = {
[Posts.POST_TYPES.ADD_TO_CHANNEL]: {
id: t('last_users_message.added_to_channel.type'),
defaultMessage: 'were **added to the channel** by {actor}.',
},
[Posts.POST_TYPES.JOIN_CHANNEL]: {
id: t('last_users_message.joined_channel.type'),
defaultMessage: '**joined the channel**.',
},
[Posts.POST_TYPES.LEAVE_CHANNEL]: {
id: t('last_users_message.left_channel.type'),
defaultMessage: '**left the channel**.',
},
[Posts.POST_TYPES.REMOVE_FROM_CHANNEL]: {
id: t('last_users_message.removed_from_channel.type'),
defaultMessage: 'were **removed from the channel**.',
},
[Posts.POST_TYPES.ADD_TO_TEAM]: {
id: t('last_users_message.added_to_team.type'),
defaultMessage: 'were **added to the team** by {actor}.',
},
[Posts.POST_TYPES.JOIN_TEAM]: {
id: t('last_users_message.joined_team.type'),
defaultMessage: '**joined the team**.',
},
[Posts.POST_TYPES.LEAVE_TEAM]: {
id: t('last_users_message.left_team.type'),
defaultMessage: '**left the team**.',
},
[Posts.POST_TYPES.REMOVE_FROM_TEAM]: {
id: t('last_users_message.removed_from_team.type'),
defaultMessage: 'were **removed from the team**.',
},
};
export default class LastUsers extends React.PureComponent {
static propTypes = {
actor: PropTypes.string,
expandedLocale: PropTypes.object.isRequired,
postType: PropTypes.string.isRequired,
style: PropTypes.object.isRequired,
textStyles: PropTypes.object,
theme: PropTypes.object.isRequired,
usernames: PropTypes.array.isRequired,
};
static defaultProps = {
usernames: [],
};
constructor(props) {
super(props);
this.state = {
expand: false,
};
}
static contextTypes = {
intl: intlShape,
};
handleOnPress = (e) => {
e.preventDefault();
this.setState({expand: true});
}
renderExpandedView = () => {
const {formatMessage} = this.context.intl;
const {
actor,
expandedLocale,
style,
textStyles,
usernames,
} = this.props;
const lastIndex = usernames.length - 1;
const lastUser = usernames[lastIndex];
const formattedMessage = formatMessage(expandedLocale, {
users: usernames.slice(0, lastIndex).join(', '),
lastUser,
actor,
});
return (
<Markdown
baseTextStyle={style.baseText}
textStyles={textStyles}
value={formattedMessage}
/>
);
}
renderCollapsedView = () => {
const {
actor,
postType,
style,
textStyles,
theme,
usernames,
} = this.props;
const firstUser = usernames[0];
const numOthers = usernames.length - 1;
return (
<Text>
<FormattedMarkdownText
id={'last_users_message.first'}
defaultMessage={'{firstUser} and '}
values={{firstUser}}
baseTextStyle={style.baseText}
style={style.baseText}
textStyles={textStyles}
theme={theme}
/>
<Text>{' '}</Text>
<Text
style={style.linkText}
onPress={this.handleOnPress}
>
<FormattedText
id={'last_users_message.others'}
defaultMessage={'{numOthers} others '}
values={{numOthers}}
/>
</Text>
<FormattedMarkdownText
id={typeMessage[postType].id}
defaultMessage={typeMessage[postType].defaultMessage}
values={{actor}}
baseTextStyle={style.baseText}
style={style.baseText}
textStyles={textStyles}
theme={theme}
/>
</Text>
);
}
render() {
if (this.state.expand) {
return this.renderExpandedView();
}
return this.renderCollapsedView();
}
}

View file

@ -1,22 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {makeGenerateCombinedPost} from '@mm-redux/utils/post_list';
import Post from 'app/components/post';
export function makeMapStateToProps() {
const generateCombinedPost = makeGenerateCombinedPost();
return (state, ownProps) => {
return {
post: generateCombinedPost(state, ownProps.combinedId),
postId: ownProps.combinedId,
};
};
}
// Note that this also passes through Post's mapStateToProps
export default connect(makeMapStateToProps)(Post);

View file

@ -198,7 +198,9 @@ exports[`UserListRow should match snapshot for currentUser with (you) populated
}
}
testID="custom_list.user_item.display_username"
/>
>
@user - you
</Text>
<BotTag
show={false}
theme={
@ -410,7 +412,9 @@ exports[`UserListRow should match snapshot for deactivated user 1`] = `
"marginTop": 2,
}
}
/>
>
Deactivated
</Text>
</View>
</View>
</CustomListRow>

View file

@ -2,13 +2,12 @@
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
import {shallowWithIntl} from 'test/intl-test-helper';
import UserListRow from './user_list_row';
jest.mock('react-intl');
jest.mock('@utils/theme', () => {
const original = jest.requireActual('../../../utils/theme');
return {
@ -18,7 +17,6 @@ jest.mock('@utils/theme', () => {
});
describe('UserListRow', () => {
const formatMessage = jest.fn();
const baseProps = {
id: '123455',
isMyUser: false,
@ -33,9 +31,8 @@ describe('UserListRow', () => {
};
test('should match snapshot', () => {
const wrapper = shallow(
const wrapper = shallowWithIntl(
<UserListRow {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@ -52,9 +49,8 @@ describe('UserListRow', () => {
user: deactivatedUser,
};
const wrapper = shallow(
const wrapper = shallowWithIntl(
<UserListRow {...newProps}/>,
{context: {intl: {formatMessage}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@ -68,9 +64,8 @@ describe('UserListRow', () => {
},
};
const wrapper = shallow(
const wrapper = shallowWithIntl(
<UserListRow {...newProps}/>,
{context: {intl: {formatMessage}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@ -84,9 +79,8 @@ describe('UserListRow', () => {
},
};
const wrapper = shallow(
const wrapper = shallowWithIntl(
<UserListRow {...newProps}/>,
{context: {intl: {formatMessage}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@ -97,9 +91,8 @@ describe('UserListRow', () => {
isMyUser: true,
};
const wrapper = shallow(
const wrapper = shallowWithIntl(
<UserListRow {...newProps}/>,
{context: {intl: {formatMessage}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});

View file

@ -46,7 +46,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
>
<View>
<View>
<FormattedText
<InjectIntl(FormattedText)
defaultMessage="Name"
id="channel_modal.name"
style={
@ -103,7 +103,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
}
>
<FormattedText
<InjectIntl(FormattedText)
defaultMessage="Purpose"
id="channel_modal.purpose"
style={
@ -114,7 +114,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
}
/>
<FormattedText
<InjectIntl(FormattedText)
defaultMessage="(optional)"
id="channel_modal.optional"
style={
@ -169,7 +169,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
/>
</View>
<View>
<FormattedText
<InjectIntl(FormattedText)
defaultMessage="Describe how this channel should be used."
id="channel_modal.descriptionHelp"
style={
@ -192,7 +192,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
}
>
<FormattedText
<InjectIntl(FormattedText)
defaultMessage="Header"
id="channel_modal.header"
style={
@ -203,7 +203,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
}
/>
<FormattedText
<InjectIntl(FormattedText)
defaultMessage="(optional)"
id="channel_modal.optional"
style={
@ -265,7 +265,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
}
>
<FormattedText
<InjectIntl(FormattedText)
defaultMessage="Set text that will appear in the header of the channel beside the channel name. For example, include frequently used links by typing [Link Title](http://example.com)."
id="channel_modal.headerHelp"
style={

View file

@ -15,7 +15,7 @@ import {General} from '@mm-redux/constants';
import Autocomplete from 'app/components/autocomplete';
import ErrorText from 'app/components/error_text';
import FormattedText from 'app/components/formatted_text';
import FormattedText from '@components/formatted_text';
import Loading from 'app/components/loading';
import StatusBar from 'app/components/status_bar';
import TextInputWithLocalizedPlaceholder from 'app/components/text_input_with_localized_placeholder';

View file

@ -1,161 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import Button from 'react-native-button';
import {intlShape} from 'react-intl';
import {preventDoubleTap} from 'app/utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {getStatusColors} from '@utils/message_attachment_colors';
import ButtonBindingText from './button_binding_text';
import {Theme} from '@mm-redux/types/preferences';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {createCallContext, createCallRequest} from '@utils/apps';
import {Channel} from '@mm-redux/types/channels';
type Props = {
actions: {
doAppCall: DoAppCall;
getChannel: (channelId: string) => Promise<ActionResult>;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
};
post: Post;
binding: AppBinding;
theme: Theme;
currentTeamID: string;
}
export default class ButtonBinding extends PureComponent<Props> {
static contextTypes = {
intl: intlShape.isRequired,
};
private mounted = false;
handleActionPress = preventDoubleTap(async () => {
const {
binding,
post,
currentTeamID,
} = this.props;
const intl = this.context.intl;
if (!binding.call) {
return;
}
let teamID = '';
const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any};
if (data) {
const channel = data as Channel;
teamID = channel.team_id;
}
const context = createCallContext(
binding.app_id,
AppBindingLocations.IN_POST + binding.location,
post.channel_id,
teamID || currentTeamID,
post.id,
);
const call = createCallRequest(
binding.call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
this.setState({executing: true});
const res = await this.props.actions.doAppCall(call, AppCallTypes.SUBMIT, this.context.intl);
if (this.mounted) {
this.setState({executing: false});
}
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage(
{id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
this.props.actions.postEphemeralCallResponseForPost(errorResponse, errorMessage, post);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
this.props.actions.postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
return;
default: {
const errorMessage = intl.formatMessage(
{
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
},
{
type: callResp.type,
},
);
this.props.actions.postEphemeralCallResponseForPost(callResp, errorMessage, post);
}
}
}, 4000);
componentDidMount() {
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
}
render() {
const {theme, binding} = this.props;
const style = getStyleSheet(theme);
return (
<Button
containerStyle={[style.button]}
disabledContainerStyle={style.buttonDisabled}
onPress={this.handleActionPress}
>
<ButtonBindingText
message={binding.label}
style={style.text}
/>
</Button>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const STATUS_COLORS = getStatusColors(theme);
return {
button: {
borderRadius: 4,
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
borderWidth: 2,
opacity: 1,
alignItems: 'center',
marginTop: 12,
justifyContent: 'center',
height: 36,
},
buttonDisabled: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
},
text: {
color: STATUS_COLORS.default,
fontSize: 15,
fontWeight: '600',
lineHeight: 17,
},
};
});

View file

@ -1,47 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {GlobalState} from '@mm-redux/types/store';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
import {getPost} from '@mm-redux/selectors/entities/posts';
import ButtonBinding from './button_binding';
import {getChannel} from '@mm-redux/actions/channels';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
type OwnProps = {
postId: string;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
return {
theme: getTheme(state),
post: getPost(state, ownProps.postId),
currentTeamID: getCurrentTeamId(state),
};
}
type Actions = {
doAppCall: DoAppCall;
getChannel: (channelId: string) => Promise<ActionResult>;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
getChannel,
postEphemeralCallResponseForPost,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ButtonBinding);

View file

@ -1,115 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {LayoutChangeEvent, ScrollView, StyleProp, View} from 'react-native';
import Markdown from '@components/markdown';
import ShowMoreButton from '@components/show_more_button';
import {Theme} from '@mm-redux/types/preferences';
const SHOW_MORE_HEIGHT = 60;
type Props = {
baseTextStyle: StyleProp<any>,
blockStyles?: StyleProp<any>[],
deviceHeight: number,
onPermalinkPress?: () => void,
textStyles?: StyleProp<any>[],
theme?: Theme,
value?: string,
}
type State = {
collapsed: boolean;
isLongText: boolean;
maxHeight?: number;
}
export default class EmbedText extends PureComponent<Props, State> {
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
const {deviceHeight} = nextProps;
const maxHeight = Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
if (maxHeight !== prevState.maxHeight) {
return {
maxHeight,
};
}
return null;
}
constructor(props: Props) {
super(props);
this.state = {
collapsed: true,
isLongText: false,
};
}
handleLayout = (event: LayoutChangeEvent) => {
const {height} = event.nativeEvent.layout;
const {maxHeight} = this.state;
if (height >= (maxHeight || 0)) {
this.setState({
isLongText: true,
});
}
};
toggleCollapseState = () => {
const {collapsed} = this.state;
this.setState({collapsed: !collapsed});
};
render() {
const {
baseTextStyle,
blockStyles,
onPermalinkPress,
textStyles,
theme,
value,
} = this.props;
const {collapsed, isLongText, maxHeight} = this.state;
if (!value) {
return null;
}
return (
<View>
<ScrollView
style={{maxHeight: (collapsed ? maxHeight : undefined), overflow: 'hidden'}}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
>
<View
onLayout={this.handleLayout}
removeClippedSubviews={isLongText && collapsed}
>
<Markdown
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
value={value}
onPermalinkPress={onPermalinkPress}
/>
</View>
</ScrollView>
{isLongText &&
<ShowMoreButton
onPress={this.toggleCollapseState}
showMore={collapsed}
theme={theme}
/>
}
</View>
);
}
}

View file

@ -1,57 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import EmbeddedBinding from './embedded_binding';
import {Theme} from '@mm-redux/types/preferences';
import {AppBinding} from '@mm-redux/types/apps';
type Props = {
embeds: AppBinding[],
baseTextStyle?: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
deviceWidth: number,
postId: string,
onPermalinkPress?: () => void,
theme: Theme,
textStyles?: StyleProp<TextStyle>[],
}
export default function EmbeddedBindings(props: Props) {
const {
embeds,
baseTextStyle,
blockStyles,
deviceHeight,
onPermalinkPress,
postId,
theme,
textStyles,
} = props;
const content = [] as React.ReactNode[];
embeds.forEach((embed, i) => {
content.push(
<EmbeddedBinding
embed={embed}
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
key={'att_' + i}
onPermalinkPress={onPermalinkPress}
postId={postId}
theme={theme}
textStyles={textStyles}
/>,
);
});
return (
<View style={{flex: 1, flexDirection: 'column'}}>
{content}
</View>
);
}

View file

@ -1,44 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {GlobalState} from '@mm-redux/types/store';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {getPost} from '@mm-redux/selectors/entities/posts';
import MenuBinding from './menu_binding';
import {getChannel} from '@mm-redux/actions/channels';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
type OwnProps = {
postId: string;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
return {
post: getPost(state, ownProps.postId),
currentTeamID: getCurrentTeamId(state),
};
}
type Actions = {
doAppCall: DoAppCall;
getChannel: (channelId: string) => Promise<ActionResult>;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
getChannel,
postEphemeralCallResponseForPost,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(MenuBinding);

View file

@ -1,137 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import AutocompleteSelector from 'app/components/autocomplete_selector';
import {intlShape} from 'react-intl';
import {PostActionOption} from '@mm-redux/types/integration_actions';
import {Post} from '@mm-redux/types/posts';
import {AppBinding} from '@mm-redux/types/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {Channel} from '@mm-redux/types/channels';
import {createCallContext, createCallRequest} from '@utils/apps';
type Props = {
actions: {
doAppCall: DoAppCall;
getChannel: (channelId: string) => Promise<ActionResult>;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
};
binding?: AppBinding;
post: Post;
currentTeamID: string;
}
type State = {
selected?: PostActionOption;
}
export default class MenuBinding extends PureComponent<Props, State> {
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props: Props) {
super(props);
this.state = {};
}
handleSelect = async (selected?: PostActionOption) => {
if (!selected) {
return;
}
this.setState({selected});
const binding = this.props.binding?.bindings?.find((b) => b.location === selected.value);
if (!binding) {
console.debug('Trying to select element not present in binding.'); //eslint-disable-line no-console
return;
}
if (!binding.call) {
return;
}
const {
actions,
post,
currentTeamID,
} = this.props;
const intl = this.context.intl;
let teamID = '';
const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any};
if (data) {
const channel = data as Channel;
teamID = channel.team_id;
}
const context = createCallContext(
binding.app_id,
AppBindingLocations.IN_POST + binding.location,
post.channel_id,
teamID || currentTeamID,
post.id,
);
const call = createCallRequest(
binding.call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
const res = await actions.doAppCall(call, AppCallTypes.SUBMIT, intl);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
this.props.actions.postEphemeralCallResponseForPost(res.error, errorMessage, post);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
this.props.actions.postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
return;
default: {
const errorMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
this.props.actions.postEphemeralCallResponseForPost(callResp, errorMessage, post);
}
}
};
render() {
const {
binding,
} = this.props;
const {selected} = this.state;
const options = binding?.bindings?.map<PostActionOption>((b:AppBinding) => {
return {text: b.label, value: b.location || ''};
});
return (
<AutocompleteSelector
placeholder={binding?.label}
options={options}
selected={selected}
onSelected={this.handleSelect}
/>
);
}
}

View file

@ -5,7 +5,7 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Text} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import FormattedText from '@components/formatted_text';
import {GlobalStyles} from 'app/styles';
import {makeStyleSheetFromTheme} from 'app/utils/theme';

View file

@ -1,67 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileAttachment should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Object {
"width": undefined,
}
}
type="opacity"
>
<FileAttachmentImage
file={
Object {
"create_at": 1546893090093,
"data": Object {
"mime_type": "image/png",
},
"delete_at": 0,
"extension": "png",
"has_preview_image": true,
"height": 171,
"id": "fileId",
"name": "image.png",
"post_id": "postId",
"size": 14894,
"update_at": 1546893090093,
"user_id": "userId",
"width": 425,
}
}
imageDimensions={null}
resizeMethod="resize"
resizeMode="cover"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</TouchableWithFeedbackIOS>
`;

View file

@ -1,38 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileAttachmentImage should match snapshot 1`] = `
<View
style={
Object {
"borderRadius": 5,
"overflow": "hidden",
}
}
>
<View
style={
Object {
"paddingBottom": "100%",
}
}
/>
<Connect(ProgressiveImage)
onError={[Function]}
resizeMethod="resize"
resizeMode="cover"
style={
Array [
Object {
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
},
undefined,
]
}
tintDefaultSource={true}
/>
</View>
`;

View file

@ -1,288 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Dimensions,
PixelRatio,
Text,
View,
StyleSheet,
} from 'react-native';
import * as Utils from '@mm-redux/utils/file_utils';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {isDocument, isImage} from '@utils/file';
import {calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import FileAttachmentDocument from './file_attachment_document';
import FileAttachmentIcon from './file_attachment_icon';
import FileAttachmentImage from './file_attachment_image';
export default class FileAttachment extends PureComponent {
static propTypes = {
canDownloadFiles: PropTypes.bool.isRequired,
file: PropTypes.object.isRequired,
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
onLongPress: PropTypes.func,
onPreviewPress: PropTypes.func,
theme: PropTypes.object.isRequired,
wrapperWidth: PropTypes.number,
isSingleImage: PropTypes.bool,
nonVisibleImagesCount: PropTypes.number,
inViewPort: PropTypes.bool,
};
static defaultProps = {
onPreviewPress: () => true,
wrapperWidth: 300,
};
state = {
resizeMode: 'cover',
};
componentWillUnmount() {
if (this.transition) {
clearTimeout(this.transition);
}
}
handlePress = () => {
this.props.onPreviewPress(this.props.index);
};
handlePreviewPress = () => {
if (this.documentElement) {
this.documentElement.handlePreviewPress();
} else {
this.props.onPreviewPress(this.props.index);
}
};
renderFileInfo() {
const {file, onLongPress, theme} = this.props;
const style = getStyleSheet(theme);
if (!file?.id) {
return null;
}
return (
<TouchableWithFeedback
onPress={this.handlePress}
onLongPress={onLongPress}
type={'opacity'}
style={style.attachmentContainer}
>
<React.Fragment>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={style.fileName}
>
{file.name.trim()}
</Text>
<View style={style.fileDownloadContainer}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={style.fileInfo}
>
{`${Utils.getFormattedFileSize(file)}`}
</Text>
</View>
</React.Fragment>
</TouchableWithFeedback>
);
}
setDocumentRef = (ref) => {
this.documentElement = ref;
};
renderMoreImagesOverlay = (value) => {
if (!value) {
return null;
}
const {theme} = this.props;
const style = getStyleSheet(theme);
return (
<View style={style.moreImagesWrapper}>
<Text style={style.moreImagesText}>
{`+${value}`}
</Text>
</View>
);
};
getImageDimensions = (file) => {
const {isSingleImage, wrapperWidth} = this.props;
const viewPortHeight = this.getViewPortHeight();
if (isSingleImage) {
return calculateDimensions(file?.height, file?.width, wrapperWidth, viewPortHeight);
}
return null;
};
getViewPortHeight = () => {
const dimensions = Dimensions.get('window');
const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
return viewPortHeight;
};
render() {
const {
canDownloadFiles,
file,
theme,
onLongPress,
isSingleImage,
nonVisibleImagesCount,
inViewPort,
} = this.props;
const style = getStyleSheet(theme);
let fileAttachmentComponent;
if (isImage(file) || file.loading) {
const imageDimensions = this.getImageDimensions(file);
fileAttachmentComponent = (
<TouchableWithFeedback
key={`${this.props.id}${file.loading}`}
onPress={this.handlePreviewPress}
onLongPress={onLongPress}
type={'opacity'}
style={{width: imageDimensions?.width}}
>
<FileAttachmentImage
file={file || {}}
inViewPort={inViewPort}
imageDimensions={imageDimensions}
isSingleImage={isSingleImage}
resizeMode={this.state.resizeMode}
theme={theme}
/>
{this.renderMoreImagesOverlay(nonVisibleImagesCount, theme)}
</TouchableWithFeedback>
);
} else if (isDocument(file)) {
fileAttachmentComponent = (
<View style={[style.fileWrapper]}>
<View style={style.iconWrapper}>
<FileAttachmentDocument
ref={this.setDocumentRef}
canDownloadFiles={canDownloadFiles}
file={file}
onLongPress={onLongPress}
theme={theme}
/>
</View>
{this.renderFileInfo()}
</View>
);
} else {
fileAttachmentComponent = (
<View style={[style.fileWrapper]}>
<View style={style.iconWrapper}>
<TouchableWithFeedback
onPress={this.handlePreviewPress}
onLongPress={onLongPress}
type={'opacity'}
>
<FileAttachmentIcon
file={file}
theme={theme}
/>
</TouchableWithFeedback>
</View>
{this.renderFileInfo()}
</View>
);
}
return fileAttachmentComponent;
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const scale = Dimensions.get('window').width / 320;
return {
attachmentContainer: {
flex: 1,
justifyContent: 'center',
},
downloadIcon: {
color: changeOpacity(theme.centerChannelColor, 0.7),
marginRight: 5,
},
fileDownloadContainer: {
flexDirection: 'row',
marginTop: 3,
},
fileInfo: {
fontSize: 14,
color: theme.centerChannelColor,
},
fileName: {
flexDirection: 'column',
flexWrap: 'wrap',
fontSize: 14,
fontWeight: '600',
color: theme.centerChannelColor,
paddingRight: 10,
},
fileWrapper: {
flex: 1,
flexDirection: 'row',
marginTop: 10,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
borderRadius: 5,
},
iconWrapper: {
marginTop: 7.8,
marginRight: 6,
marginBottom: 8.2,
marginLeft: 8,
},
circularProgress: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
circularProgressContent: {
position: 'absolute',
height: '100%',
width: '100%',
top: 0,
left: 0,
alignItems: 'center',
justifyContent: 'center',
},
moreImagesWrapper: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 5,
},
moreImagesText: {
color: theme.sidebarHeaderTextColor,
fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)),
fontFamily: 'Open Sans',
textAlign: 'center',
},
};
});

View file

@ -1,45 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import FileAttachment from './file_attachment.js';
import Preferences from '@mm-redux/constants/preferences';
jest.mock('react-native-file-viewer', () => ({
open: jest.fn(),
}));
describe('FileAttachment', () => {
const baseProps = {
canDownloadFiles: true,
file: {
create_at: 1546893090093,
delete_at: 0,
extension: 'png',
has_preview_image: true,
height: 171,
id: 'fileId',
name: 'image.png',
post_id: 'postId',
size: 14894,
update_at: 1546893090093,
user_id: 'userId',
width: 425,
data: {
mime_type: 'image/png',
},
},
id: 'id',
index: 0,
theme: Preferences.THEMES.default,
};
test('should match snapshot', () => {
const wrapper = shallow(
<FileAttachment {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -1,311 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Alert,
Platform,
StatusBar,
StyleSheet,
View,
} from 'react-native';
import FileViewer from 'react-native-file-viewer';
import RNFetchBlob from 'rn-fetch-blob';
import {intlShape} from 'react-intl';
import tinyColor from 'tinycolor2';
import FileAttachmentIcon from '@components/file_attachment_list/file_attachment_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import ProgressBar from '@components/progress_bar';
import {DeviceTypes} from '@constants/';
import {getFileUrl} from '@mm-redux/utils/file_utils';
import {getLocalFilePathFromFile} from '@utils/file';
import mattermostBucket from 'app/mattermost_bucket';
const {DOCUMENTS_PATH} = DeviceTypes;
export default class FileAttachmentDocument extends PureComponent {
static propTypes = {
backgroundColor: PropTypes.string,
canDownloadFiles: PropTypes.bool.isRequired,
file: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
onLongPress: PropTypes.func,
};
static contextTypes = {
intl: intlShape,
};
state = {
didCancel: false,
downloading: false,
preview: false,
progress: 0,
};
componentDidMount() {
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
}
cancelDownload = () => {
if (this.mounted) {
this.setState({didCancel: true});
}
if (this.downloadTask) {
this.downloadTask.cancel();
}
};
setStatusBarColor = (style) => {
if (Platform.OS === 'ios') {
if (style) {
StatusBar.setBarStyle(style, true);
} else {
const {theme} = this.props;
const headerColor = tinyColor(theme.sidebarHeaderBg);
let barStyle = 'light-content';
if (headerColor.isLight() && Platform.OS === 'ios') {
barStyle = 'dark-content';
}
StatusBar.setBarStyle(barStyle, true);
}
}
};
downloadAndPreviewFile = async (file) => {
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, file);
this.setState({didCancel: false});
try {
const certificate = await mattermostBucket.getPreference('cert');
const isDir = await RNFetchBlob.fs.isDir(DOCUMENTS_PATH);
if (!isDir) {
try {
await RNFetchBlob.fs.mkdir(DOCUMENTS_PATH);
} catch (error) {
this.showDownloadFailedAlert();
return;
}
}
const options = {
session: file.id,
timeout: 10000,
indicator: true,
overwrite: true,
path,
certificate,
};
const exist = await RNFetchBlob.fs.exists(path);
if (exist) {
this.openDocument(file);
} else {
this.setState({downloading: true});
this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(file.id));
this.downloadTask.progress((received, total) => {
const progress = parseFloat((received / total).toFixed(1));
if (this.mounted) {
this.setState({progress});
}
});
await this.downloadTask;
if (this.mounted) {
this.setState({
progress: 1,
}, () => {
this.openDocument(file);
});
}
}
} catch (error) {
RNFetchBlob.fs.unlink(path);
if (this.mounted) {
this.setState({downloading: false, progress: 0});
if (error.message !== 'cancelled') {
this.showDownloadFailedAlert();
}
}
}
};
handlePreviewPress = async () => {
const {canDownloadFiles, file} = this.props;
const {downloading, progress} = this.state;
if (!canDownloadFiles) {
this.showDownloadDisabledAlert();
return;
}
if (downloading && progress < 1) {
this.cancelDownload();
} else if (downloading) {
this.resetViewState();
} else {
this.downloadAndPreviewFile(file);
}
};
onDonePreviewingFile = () => {
if (this.mounted) {
this.setState({progress: 0, downloading: false, preview: false});
}
this.setStatusBarColor();
};
openDocument = (file) => {
if (!this.state.didCancel && !this.state.preview && this.mounted) {
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, file);
this.setState({preview: true});
this.setStatusBarColor('dark-content');
FileViewer.open(path, {
displayName: file.name,
onDismiss: this.onDonePreviewingFile,
showOpenWithDialog: true,
showAppsSuggestions: true,
}).then(() => {
if (this.mounted) {
this.setState({downloading: false, progress: 0});
}
}).catch(() => {
const {intl} = this.context;
Alert.alert(
intl.formatMessage({
id: 'mobile.document_preview.failed_title',
defaultMessage: 'Open Document failed',
}),
intl.formatMessage({
id: 'mobile.document_preview.failed_description',
defaultMessage: 'An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n',
}, {
fileType: file.extension.toUpperCase(),
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}],
);
this.onDonePreviewingFile();
RNFetchBlob.fs.unlink(path);
});
}
};
resetViewState = () => {
if (this.mounted) {
this.setState({
progress: 0,
didCancel: true,
downloading: false,
});
}
};
showDownloadDisabledAlert = () => {
const {intl} = this.context;
Alert.alert(
intl.formatMessage({
id: 'mobile.downloader.disabled_title',
defaultMessage: 'Download disabled',
}),
intl.formatMessage({
id: 'mobile.downloader.disabled_description',
defaultMessage: 'File downloads are disabled on this server. Please contact your System Admin for more details.\n',
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}],
);
};
showDownloadFailedAlert = () => {
const {intl} = this.context;
Alert.alert(
intl.formatMessage({
id: 'mobile.downloader.failed_title',
defaultMessage: 'Download failed',
}),
intl.formatMessage({
id: 'mobile.downloader.failed_description',
defaultMessage: 'An error occurred while downloading the file. Please check your internet connection and try again.\n',
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}],
);
};
renderFileAttachmentIcon = () => {
const {backgroundColor, file, theme} = this.props;
return (
<FileAttachmentIcon
backgroundColor={backgroundColor}
file={file}
theme={theme}
/>
);
}
render() {
const {onLongPress, theme} = this.props;
const {downloading, progress} = this.state;
let fileAttachmentComponent;
if (downloading) {
fileAttachmentComponent = (
<>
{this.renderFileAttachmentIcon()}
<View style={[StyleSheet.absoluteFill, styles.progress]}>
<ProgressBar
progress={progress || 0.1}
color={theme.buttonBg}
/>
</View>
</>
);
} else {
fileAttachmentComponent = this.renderFileAttachmentIcon();
}
return (
<TouchableWithFeedback
onPress={this.handlePreviewPress}
onLongPress={onLongPress}
type={'opacity'}
>
{fileAttachmentComponent}
</TouchableWithFeedback>
);
}
}
const styles = StyleSheet.create({
progress: {
justifyContent: 'flex-end',
height: 48,
left: 2,
top: 5,
width: 44,
},
});

View file

@ -1,92 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import * as Utils from '@mm-redux/utils/file_utils';
const BLUE_ICON = '#338AFF';
const RED_ICON = '#ED522A';
const GREEN_ICON = '#1CA660';
const GRAY_ICON = '#999999';
const FAILED_ICON_NAME_AND_COLOR = ['jumbo-attachment-image-broken', GRAY_ICON];
const ICON_NAME_AND_COLOR_FROM_FILE_TYPE = {
audio: ['jumbo-attachment-audio', BLUE_ICON],
code: ['jumbo-attachment-code', BLUE_ICON],
image: ['jumbo-attachment-image', BLUE_ICON],
smallImage: ['image-outline', BLUE_ICON],
other: ['jumbo-attachment-generic', BLUE_ICON],
patch: ['jumbo-attachment-patch', BLUE_ICON],
pdf: ['jumbo-attachment-pdf', RED_ICON],
presentation: ['jumbo-attachment-powerpoint', RED_ICON],
spreadsheet: ['jumbo-attachment-excel', GREEN_ICON],
text: ['jumbo-attachment-text', GRAY_ICON],
video: ['jumbo-attachment-video', BLUE_ICON],
word: ['jumbo-attachment-word', BLUE_ICON],
zip: ['jumbo-attachment-zip', BLUE_ICON],
};
export default class FileAttachmentIcon extends PureComponent {
static propTypes = {
backgroundColor: PropTypes.string,
failed: PropTypes.bool,
defaultImage: PropTypes.bool,
smallImage: PropTypes.bool,
file: PropTypes.object,
iconColor: PropTypes.string,
iconSize: PropTypes.number,
theme: PropTypes.object,
};
static defaultProps = {
failed: false,
defaultImage: false,
smallImage: false,
iconSize: 48,
};
getFileIconNameAndColor(file) {
if (this.props.failed) {
return FAILED_ICON_NAME_AND_COLOR;
}
if (this.props.defaultImage) {
if (this.props.smallImage) {
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.smallImage;
}
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.image;
}
const fileType = Utils.getFileType(file);
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE[fileType] || ICON_NAME_AND_COLOR_FROM_FILE_TYPE.other;
}
render() {
const {backgroundColor, file, iconSize, theme, iconColor} = this.props;
const [iconName, defaultIconColor] = this.getFileIconNameAndColor(file);
const color = iconColor || defaultIconColor;
const bgColor = backgroundColor || theme?.centerChannelBg || 'transparent';
return (
<View style={[styles.fileIconWrapper, {backgroundColor: bgColor}]}>
<CompassIcon
name={iconName}
size={iconSize}
color={color}
/>
</View>
);
}
}
const styles = StyleSheet.create({
fileIconWrapper: {
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
},
});

View file

@ -1,195 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
View,
StyleSheet,
} from 'react-native';
import ProgressiveImage from '@components/progressive_image';
import {Client4} from '@client/rest';
import {changeOpacity} from '@utils/theme';
import FileAttachmentIcon from './file_attachment_icon';
const SMALL_IMAGE_MAX_HEIGHT = 48;
const SMALL_IMAGE_MAX_WIDTH = 48;
const IMAGE_SIZE = {
Fullsize: 'fullsize',
Preview: 'preview',
Thumbnail: 'thumbnail',
};
export default class FileAttachmentImage extends PureComponent {
static propTypes = {
file: PropTypes.object.isRequired,
imageHeight: PropTypes.number,
imageSize: PropTypes.oneOf([
IMAGE_SIZE.Fullsize,
IMAGE_SIZE.Preview,
IMAGE_SIZE.Thumbnail,
]),
imageWidth: PropTypes.number,
theme: PropTypes.object,
resizeMode: PropTypes.string,
resizeMethod: PropTypes.string,
isSingleImage: PropTypes.bool,
imageDimensions: PropTypes.object,
backgroundColor: PropTypes.string,
inViewPort: PropTypes.bool,
};
static defaultProps = {
resizeMode: 'cover',
resizeMethod: 'resize',
};
state = {
failed: false,
};
boxPlaceholder = () => {
if (this.props.isSingleImage) {
return null;
}
return (<View style={style.boxPlaceholder}/>);
};
handleError = () => {
this.setState({failed: true});
}
imageProps = (file) => {
const imageProps = {};
if (file.localPath) {
imageProps.defaultSource = {uri: file.localPath};
} else if (file.id) {
if (file.mini_preview && file.mime_type) {
imageProps.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`;
} else {
imageProps.thumbnailUri = Client4.getFileThumbnailUrl(file.id);
}
imageProps.imageUri = Client4.getFilePreviewUrl(file.id);
imageProps.inViewPort = this.props.inViewPort;
}
return imageProps;
};
renderSmallImage = () => {
const {file, isSingleImage, resizeMethod, theme} = this.props;
let wrapperStyle = style.fileImageWrapper;
if (isSingleImage) {
wrapperStyle = style.singleSmallImageWrapper;
if (file.width > SMALL_IMAGE_MAX_WIDTH) {
wrapperStyle = [wrapperStyle, {width: '100%'}];
}
}
return (
<View
style={[
wrapperStyle,
style.smallImageBorder,
{
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
]}
>
{this.boxPlaceholder()}
<View style={style.smallImageOverlay}>
<ProgressiveImage
id={file.id}
style={{height: file.height, width: file.width}}
tintDefaultSource={!file.localPath && !this.state.failed}
filename={file.name}
onError={this.handleError}
resizeMode={'contain'}
resizeMethod={resizeMethod}
{...this.imageProps(file)}
/>
</View>
</View>
);
};
render() {
const {failed} = this.state;
const {
file,
imageDimensions,
resizeMethod,
resizeMode,
backgroundColor,
theme,
} = this.props;
if (failed) {
return (
<FileAttachmentIcon
failed={failed}
backgroundColor={backgroundColor}
theme={theme}
/>
);
}
if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) {
return this.renderSmallImage();
}
const imageProps = this.imageProps(file);
return (
<View
style={style.fileImageWrapper}
>
{this.boxPlaceholder()}
<ProgressiveImage
id={file.id}
style={[this.props.isSingleImage ? null : style.imagePreview, imageDimensions]}
tintDefaultSource={!file.localPath && !this.state.failed}
filename={file.name}
onError={this.handleError}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
{...imageProps}
/>
</View>
);
}
}
const style = StyleSheet.create({
imagePreview: {
...StyleSheet.absoluteFill,
},
fileImageWrapper: {
borderRadius: 5,
overflow: 'hidden',
},
boxPlaceholder: {
paddingBottom: '100%',
},
smallImageBorder: {
borderRadius: 5,
},
smallImageOverlay: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 4,
},
singleSmallImageWrapper: {
height: SMALL_IMAGE_MAX_HEIGHT,
width: SMALL_IMAGE_MAX_WIDTH,
overflow: 'hidden',
},
});

View file

@ -1,52 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Client4} from '@client/rest';
import FileAttachmentImage from './file_attachment_image.js';
describe('FileAttachmentImage', () => {
const baseProps = {
file: {},
};
test('should match snapshot', () => {
const wrapper = shallow(
<FileAttachmentImage {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
describe('imageProps', () => {
const wrapper = shallow(
<FileAttachmentImage {...baseProps}/>,
);
const instance = wrapper.instance();
it('should have file.localPath as defaultSource if localPath is set', () => {
wrapper.setState({failed: false});
const file = {localPath: '/localPath.png'};
const imageProps = instance.imageProps(file);
expect(imageProps.defaultSource).toStrictEqual({uri: file.localPath});
expect(imageProps.thumbnailUri).toBeUndefined();
expect(imageProps.imageUri).toBeUndefined();
});
it('should have thumbnailUri and imageUri if the file has an ID', () => {
const getFileThumbnailUrl = jest.spyOn(Client4, 'getFileThumbnailUrl');
const getFilePreviewUrl = jest.spyOn(Client4, 'getFilePreviewUrl');
wrapper.setState({failed: false});
const file = {id: 'id'};
const imageProps = instance.imageProps(file);
expect(getFileThumbnailUrl).toHaveBeenCalled();
expect(getFilePreviewUrl).toHaveBeenCalled();
expect(imageProps.defaultSource).toBeUndefined();
expect(imageProps.thumbnailUri).toBeDefined();
expect(imageProps.imageUri).toBeDefined();
});
});
});

View file

@ -1,238 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {StyleSheet, View, DeviceEventEmitter} from 'react-native';
import ImageViewPort from '@components/image_viewport';
import {Client4} from '@client/rest';
import {isDocument, isGif, isImage, isVideo} from '@utils/file';
import {getViewPortWidth, openGalleryAtIndex} from '@utils/images';
import {preventDoubleTap} from '@utils/tap';
import FileAttachment from './file_attachment';
const MAX_VISIBLE_ROW_IMAGES = 4;
export default class FileAttachmentList extends ImageViewPort {
static propTypes = {
canDownloadFiles: PropTypes.bool.isRequired,
fileIds: PropTypes.array.isRequired,
files: PropTypes.array,
isFailed: PropTypes.bool,
onLongPress: PropTypes.func,
postId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
isReplyPost: PropTypes.bool,
};
static defaultProps = {
files: [],
};
constructor(props) {
super(props);
this.filesForGallery = this.getFilesForGallery(props);
this.buildGalleryFiles().then((results) => {
this.galleryFiles = results;
});
}
componentDidMount() {
super.componentDidMount();
this.onScrollEnd = DeviceEventEmitter.addListener('scrolled', (viewableItems) => {
if (this.props.postId in viewableItems) {
this.setState({
inViewPort: true,
});
}
});
}
componentDidUpdate(prevProps) {
if (prevProps.files.length !== this.props.files.length) {
this.filesForGallery = this.getFilesForGallery(this.props);
this.buildGalleryFiles().then((results) => {
this.galleryFiles = results;
});
}
}
componentWillUnmount() {
super.componentWillUnmount();
if (this.onScrollEnd && this.onScrollEnd.remove) {
this.onScrollEnd.remove();
}
}
attachmentIndex = (fileId) => {
return this.filesForGallery.findIndex((file) => file.id === fileId) || 0;
};
attachmentManifest = (attachments) => {
return attachments.reduce((info, file) => {
if (isImage(file)) {
info.imageAttachments.push(file);
} else {
info.nonImageAttachments.push(file);
}
return info;
}, {imageAttachments: [], nonImageAttachments: []});
};
buildGalleryFiles = async () => {
const results = [];
if (this.filesForGallery && this.filesForGallery.length) {
for (let i = 0; i < this.filesForGallery.length; i++) {
const file = this.filesForGallery[i];
if (isDocument(file) || isVideo(file) || (!isImage(file))) {
results.push(file);
continue;
}
let uri;
if (file.localPath) {
uri = file.localPath;
} else {
uri = isGif(file) ? Client4.getFileUrl(file.id) : Client4.getFilePreviewUrl(file.id);
}
results.push({
...file,
uri,
});
}
}
return results;
};
getFilesForGallery = (props) => {
const manifest = this.attachmentManifest(props.files);
const files = manifest.imageAttachments.concat(manifest.nonImageAttachments);
const results = [];
if (files && files.length) {
files.forEach((file) => {
results.push(file);
});
}
return results;
};
handlePreviewPress = preventDoubleTap((idx) => {
openGalleryAtIndex(idx, this.galleryFiles);
});
isSingleImage = (files) => (files.length === 1 && isImage(files[0]));
renderItems = (items, moreImagesCount, includeGutter = false) => {
const {canDownloadFiles, isReplyPost, onLongPress, theme} = this.props;
const isSingleImage = this.isSingleImage(items);
let nonVisibleImagesCount;
let container = styles.container;
const containerWithGutter = [container, styles.gutter];
return items.map((file, idx) => {
if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) {
nonVisibleImagesCount = moreImagesCount;
}
if (idx !== 0 && includeGutter) {
container = containerWithGutter;
}
return (
<View
style={container}
key={file.id}
>
<FileAttachment
key={file.id}
canDownloadFiles={canDownloadFiles}
file={file}
id={file.id}
index={this.attachmentIndex(file.id)}
onPreviewPress={this.handlePreviewPress}
onLongPress={onLongPress}
theme={theme}
isSingleImage={isSingleImage}
nonVisibleImagesCount={nonVisibleImagesCount}
wrapperWidth={getViewPortWidth(isReplyPost, this.hasPermanentSidebar())}
inViewPort={this.state.inViewPort}
/>
</View>
);
});
};
renderImageRow = (images) => {
if (images.length === 0) {
return null;
}
const {isReplyPost} = this.props;
const visibleImages = images.slice(0, MAX_VISIBLE_ROW_IMAGES);
const hasFixedSidebar = this.hasPermanentSidebar();
const portraitPostWidth = getViewPortWidth(isReplyPost, hasFixedSidebar);
let nonVisibleImagesCount;
if (images.length > MAX_VISIBLE_ROW_IMAGES) {
nonVisibleImagesCount = images.length - MAX_VISIBLE_ROW_IMAGES;
}
return (
<View style={[styles.row, {width: portraitPostWidth}]}>
{ this.renderItems(visibleImages, nonVisibleImagesCount, true) }
</View>
);
};
render() {
const {canDownloadFiles, fileIds, files, isFailed} = this.props;
if (!files.length && fileIds.length > 0) {
return fileIds.map((id, idx) => (
<FileAttachment
key={id}
canDownloadFiles={canDownloadFiles}
file={{loading: true}}
id={id}
index={idx}
theme={this.props.theme}
/>
));
}
const manifest = this.attachmentManifest(files);
return (
<View style={[isFailed && styles.failed]}>
{this.renderImageRow(manifest.imageAttachments)}
{this.renderItems(manifest.nonImageAttachments)}
</View>
);
}
}
const styles = StyleSheet.create({
row: {
flex: 1,
flexDirection: 'row',
marginTop: 5,
},
container: {
flex: 1,
},
gutter: {
marginLeft: 8,
},
failed: {
opacity: 0.5,
},
});

View file

@ -1,173 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import FileAttachment from './file_attachment_list.js';
import Preferences from '@mm-redux/constants/preferences';
jest.mock('react-native-file-viewer', () => ({
open: jest.fn(),
}));
describe('FileAttachmentList', () => {
const files = [{
create_at: 1546893090093,
delete_at: 0,
extension: 'png',
has_preview_image: true,
height: 171,
id: 'fileId',
mime_type: 'image/png',
name: 'image01.png',
post_id: 'postId',
size: 14894,
update_at: 1546893090093,
user_id: 'userId',
width: 425,
},
{
create_at: 1546893090093,
delete_at: 0,
extension: 'png',
has_preview_image: true,
height: 800,
id: 'otherFileId',
mime_type: 'image/png',
name: 'image02.png',
post_id: 'postId',
size: 24894,
update_at: 1546893090093,
user_id: 'userId',
width: 555,
}];
const nonImage = {
extension: 'other',
id: 'fileId',
mime_type: 'other/type',
name: 'file01.other',
post_id: 'postId',
size: 14894,
user_id: 'userId',
};
const baseProps = {
canDownloadFiles: true,
deviceHeight: 680,
deviceWidth: 660,
fileIds: ['fileId'],
files: [files[0]],
postId: 'postId',
theme: Preferences.THEMES.default,
};
test('should match snapshot with a single image file', () => {
const wrapper = shallow(
<FileAttachment {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with two image files', () => {
const props = {
...baseProps,
files,
};
const wrapper = shallow(
<FileAttachment {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with three image files', () => {
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
const props = {
...baseProps,
files: [...files, thirdImage],
};
const wrapper = shallow(
<FileAttachment {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with four image files', () => {
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
const fourthImage = {...files[1], id: 'fourthFileId', name: 'image04.png'};
const props = {
...baseProps,
files: [...files, thirdImage, fourthImage],
};
const wrapper = shallow(
<FileAttachment {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with more than four image files', () => {
const thirdImage = {...files[1], id: 'thirdFileId', name: 'image03.png'};
const fourthImage = {...files[1], id: 'fourthFileId', name: 'image04.png'};
const fifthImage = {...files[1], id: 'fifthFileId', name: 'image05.png'};
const sixthImage = {...files[1], id: 'sixthFileId', name: 'image06.png'};
const props = {
...baseProps,
files: [...files, thirdImage, fourthImage, fifthImage, sixthImage],
};
const wrapper = shallow(
<FileAttachment {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with non-image attachment', () => {
const props = {
...baseProps,
files: [nonImage],
};
const wrapper = shallow(
<FileAttachment {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with combination of image and non-image attachments', () => {
const props = {
...baseProps,
files: [...files, nonImage],
};
const wrapper = shallow(
<FileAttachment {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should call getFilesForGallery on props change', async () => {
const props = {
...baseProps,
};
const wrapper = shallow(
<FileAttachment {...props}/>,
);
wrapper.instance().getFilesForGallery = jest.fn().mockImplementationOnce(() => []);
wrapper.setProps({files: [files[0], files[1]]});
expect(wrapper.instance().getFilesForGallery).toHaveBeenCalled();
});
});

View file

@ -1,35 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment-timezone';
import {Text} from 'react-native';
export default class FormattedDate extends React.PureComponent {
static propTypes = {
format: PropTypes.string,
timeZone: PropTypes.string,
value: PropTypes.any.isRequired,
};
static defaultProps = {
format: 'ddd, MMM DD, YYYY',
};
render() {
const {
format,
timeZone,
value,
...props
} = this.props;
let formattedDate = moment(value).format(format);
if (timeZone) {
formattedDate = moment.tz(value, timeZone).format(format);
}
return <Text {...props}>{formattedDate}</Text>;
}
}

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React from 'react';
import {Text, TextProps} from 'react-native';
import type {UserTimezone} from '@mm-redux/types/users';
type FormattedDateProps = TextProps & {
format: string;
timezone?: string | UserTimezone | null;
value: number | string | Date;
}
const FormattedDate = ({format, timezone, value, ...props}: FormattedDateProps) => {
let formattedDate = moment(value).format(format);
if (timezone) {
let zone = timezone as string;
if (typeof timezone === 'object') {
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
}
formattedDate = moment.tz(value, zone).format(format);
}
return <Text {...props}>{formattedDate}</Text>;
};
FormattedDate.defaultProps = {
format: 'ddd, MMM DD, YYYY',
};
export default FormattedDate;

View file

@ -1,96 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {createElement, isValidElement} from 'react';
import PropTypes from 'prop-types';
import {Text} from 'react-native';
import {intlShape} from 'react-intl';
export default class FormattedText extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string,
values: PropTypes.object,
testID: PropTypes.string,
};
static defaultProps = {
defaultMessage: '',
};
static contextTypes = {
intl: intlShape.isRequired,
};
render() {
const {
id,
defaultMessage,
values,
...props
} = this.props;
const {formatMessage} = this.context.intl;
let tokenDelimiter;
let tokenizedValues;
let elements;
const hasValues = values && Object.keys(values).length > 0;
if (hasValues) {
// Creates a token with a random UID that should not be guessable or
// conflict with other parts of the `message` string.
const uid = Math.floor(Math.random() * 0x10000000000).toString(16);
const generateToken = (() => {
let counter = 0;
return () => {
const elementId = `ELEMENT-${uid}-${counter += 1}`;
return elementId;
};
})();
// Splitting with a delimiter to support IE8. When using a regex
// with a capture group IE8 does not include the capture group in
// the resulting array.
tokenDelimiter = `@__${uid}__@`;
tokenizedValues = {};
elements = {};
// Iterates over the `props` to keep track of any React Element
// values so they can be represented by the `token` as a placeholder
// when the `message` is formatted. This allows the formatted
// message to then be broken-up into parts with references to the
// React Elements inserted back in.
Object.keys(values).forEach((name) => {
const value = values[name];
if (isValidElement(value)) {
const token = generateToken();
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;
elements[token] = value;
} else {
tokenizedValues[name] = value;
}
});
}
const descriptor = {id, defaultMessage};
const formattedMessage = formatMessage(descriptor, tokenizedValues || values);
const hasElements = elements && Object.keys(elements).length > 0;
let nodes;
if (hasElements) {
// Split the message into parts so the React Element values captured
// above can be inserted back into the rendered message. This
// approach allows messages to render with React Elements while
// keeping React's virtual diffing working properly.
nodes = formattedMessage.
split(tokenDelimiter).
filter((part) => Boolean(part)).
map((part) => elements[part] || part);
} else {
nodes = [formattedMessage];
}
return createElement(Text, props, ...nodes);
}
}

View file

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {createElement, isValidElement} from 'react';
import {Text, TextProps} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
type FormattedTextProps = TextProps & {
id: string;
defaultMessage: string;
intl: typeof intlShape;
values?: Record<string, React.ReactElement<unknown, string | React.JSXElementConstructor<any>>>;
}
const FormattedText = ({id, defaultMessage, intl, values, ...props}: FormattedTextProps) => {
const {formatMessage} = intl;
let tokenDelimiter = '';
const tokenizedValues: Record<string, string|number> = {};
const elements: Record<string, React.ReactElement<unknown, string | React.JSXElementConstructor<any>>> = {};
if (values && Object.keys(values).length > 0) {
// Creates a token with a random UID that should not be guessable or
// conflict with other parts of the `message` string.
const uid = Math.floor(Math.random() * 0x10000000000).toString(16);
const generateToken = (() => {
let counter = 0;
return () => {
const elementId = `ELEMENT-${uid}-${counter += 1}`;
return elementId;
};
})();
// Splitting with a delimiter to support IE8. When using a regex
// with a capture group IE8 does not include the capture group in
// the resulting array.
tokenDelimiter = `@__${uid}__@`;
// Iterates over the `props` to keep track of any React Element
// values so they can be represented by the `token` as a placeholder
// when the `message` is formatted. This allows the formatted
// message to then be broken-up into parts with references to the
// React Elements inserted back in.
Object.keys(values).forEach((name) => {
const value = values[name];
if (isValidElement(value)) {
const token = generateToken();
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;
elements[token] = value;
} else {
tokenizedValues[name] = value;
}
});
}
const descriptor = {id, defaultMessage};
const formattedMessage = formatMessage(descriptor, tokenizedValues || values);
const hasElements = elements && Object.keys(elements).length > 0;
let nodes;
if (hasElements) {
// Split the message into parts so the React Element values captured
// above can be inserted back into the rendered message. This
// approach allows messages to render with React Elements while
// keeping React's virtual diffing working properly.
nodes = formattedMessage.
split(tokenDelimiter).
filter((part: string) => Boolean(part)).
map((part: string) => elements[part] || part);
} else {
nodes = [formattedMessage];
}
return createElement(Text, props, ...nodes);
};
FormattedText.defautProps = {
defaultMessage: '',
};
export default injectIntl(FormattedText);

View file

@ -1,56 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {Text} from 'react-native';
import moment from 'moment-timezone';
export default class FormattedTime extends React.PureComponent {
static propTypes = {
value: PropTypes.any.isRequired,
timeZone: PropTypes.string,
children: PropTypes.func,
hour12: PropTypes.bool,
style: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
testID: PropTypes.string,
};
getFormattedTime = () => {
const {
value,
timeZone,
hour12,
} = this.props;
let format = 'H:mm';
if (hour12) {
const localeFormat = moment.localeData().longDateFormat('LT');
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
}
if (timeZone) {
return moment.tz(value, timeZone).format(format);
}
return moment(value).format(format);
};
render() {
const {children, style, testID} = this.props;
const formattedTime = this.getFormattedTime();
if (typeof children === 'function') {
return children(formattedTime);
}
return (
<Text
style={style}
testID={testID}
>
{formattedTime}
</Text>
);
}
}

View file

@ -11,8 +11,8 @@ import FormattedTime from './formatted_time';
describe('FormattedTime', () => {
const baseProps = {
value: 1548788533405,
timeZone: 'UTC',
hour12: true,
timezone: 'UTC',
isMilitaryTime: false,
};
it('should render correctly', () => {
@ -28,7 +28,7 @@ describe('FormattedTime', () => {
const viewTwo = renderWithIntl(
<FormattedTime
{...baseProps}
hour12={false}
isMilitaryTime={true}
/>,
);
@ -58,7 +58,7 @@ describe('FormattedTime', () => {
const koViewTwo = renderWithIntl(
<FormattedTime
{...baseProps}
hour12={false}
isMilitaryTime={true}
/>,
'ko',
);
@ -72,7 +72,7 @@ describe('FormattedTime', () => {
const viewOne = renderWithIntl(
<FormattedTime
{...baseProps}
timeZone='NZ-CHAT'
timezone='NZ-CHAT'
/>,
'es',
);
@ -83,8 +83,8 @@ describe('FormattedTime', () => {
const viewTwo = renderWithIntl(
<FormattedTime
{...baseProps}
timeZone='NZ-CHAT'
hour12={false}
timezone='NZ-CHAT'
isMilitaryTime={true}
/>,
'es',
);

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React from 'react';
import {Text, TextProps} from 'react-native';
import type {UserTimezone} from '@mm-redux/types/users';
type FormattedTimeProps = TextProps & {
isMilitaryTime: boolean;
timezone: UserTimezone | string;
value: number | string | Date;
}
const FormattedTime = ({isMilitaryTime, timezone, value, ...props}: FormattedTimeProps) => {
const getFormattedTime = () => {
let format = 'H:mm';
if (!isMilitaryTime) {
const localeFormat = moment.localeData().longDateFormat('LT');
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
}
let zone = timezone as string;
if (typeof timezone === 'object') {
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
}
return timezone ? moment.tz(value, zone).format(format) : moment(value).format(format);
};
const formattedTime = getFormattedTime();
return (
<Text {...props}>
{formattedTime}
</Text>
);
};
export default FormattedTime;

View file

@ -10,9 +10,12 @@ import EventEmitter from '@mm-redux/utils/event_emitter';
import mattermostManaged from 'app/mattermost_managed';
// TODO: Use permanentSidebar and splitView hooks instead
export default class ImageViewPort extends PureComponent {
mounted = false;
state = {
inViewPort: false,
isSplitView: false,
permanentSidebar: false,
};
componentDidMount() {
@ -32,7 +35,7 @@ export default class ImageViewPort extends PureComponent {
handleDimensions = () => {
if (this.mounted) {
if (DeviceTypes.IS_TABLET) {
mattermostManaged.isRunningInSplitView().then((result) => {
mattermostManaged.isRunningInSplitView().then((result: any) => {
const isSplitView = Boolean(result.isSplitView);
this.setState({isSplitView});
});

View file

@ -2,18 +2,16 @@
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {shallowWithIntl} from 'test/intl-test-helper';
import InteractiveDialogController from './interactive_dialog_controller';
jest.mock('react-intl');
describe('InteractiveDialogController', () => {
test('should open interactive dialog as alert or screen depending on with or without element', () => {
let baseProps = getBaseProps('trigger_id_1');
const wrapper = shallow(
const wrapper = shallowWithIntl(
<InteractiveDialogController {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
const instance = wrapper.instance();

View file

@ -9,7 +9,7 @@ import {
ViewPropTypes,
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import {t} from 'app/utils/i18n';

View file

@ -11,26 +11,16 @@ export default class Hashtag extends React.PureComponent {
static propTypes = {
hashtag: PropTypes.string.isRequired,
linkStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
onHashtagPress: PropTypes.func,
};
handlePress = async () => {
const {
onHashtagPress,
hashtag,
} = this.props;
if (onHashtagPress) {
onHashtagPress(hashtag);
return;
}
const {hashtag} = this.props;
// Close thread view, permalink view, etc
await dismissAllModals();
await popToRoot();
showSearchModal('#' + this.props.hashtag);
showSearchModal('#' + hashtag);
};
render() {

View file

@ -37,25 +37,4 @@ describe('Hashtag', () => {
expect(popToRoot).toHaveBeenCalled();
expect(showSearchModal).toHaveBeenCalledWith('#test');
});
test('handlePress should call onHashtagPress if provided', async () => {
const dismissAllModals = jest.spyOn(NavigationActions, 'dismissAllModals');
const popToRoot = jest.spyOn(NavigationActions, 'popToRoot');
const showSearchModal = jest.spyOn(NavigationActions, 'showSearchModal');
const props = {
...baseProps,
onHashtagPress: jest.fn(),
};
const wrapper = shallow(<Hashtag {...props}/>);
await wrapper.instance().handlePress();
expect(dismissAllModals).not.toBeCalled();
expect(popToRoot).not.toBeCalled();
expect(showSearchModal).not.toBeCalled();
expect(props.onHashtagPress).toBeCalled();
});
});

View file

@ -49,13 +49,11 @@ export default class Markdown extends PureComponent {
mentionKeys: PropTypes.array,
minimumHashtagLength: PropTypes.number,
onChannelLinkPress: PropTypes.func,
onHashtagPress: PropTypes.func,
onPermalinkPress: PropTypes.func,
onPostPress: PropTypes.func,
postId: PropTypes.string,
textStyles: PropTypes.object,
theme: PropTypes.object,
value: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
disableHashtags: PropTypes.bool,
disableAtMentions: PropTypes.bool,
disableChannelLink: PropTypes.bool,
@ -274,7 +272,6 @@ export default class Markdown extends PureComponent {
<Hashtag
hashtag={hashtag}
linkStyle={this.props.textStyles.link}
onHashtagPress={this.props.onHashtagPress}
/>
);
};
@ -417,10 +414,7 @@ export default class Markdown extends PureComponent {
renderLink = ({children, href}) => {
return (
<MarkdownLink
href={href}
onPermalinkPress={this.props.onPermalinkPress}
>
<MarkdownLink href={href}>
{children}
</MarkdownLink>
);

View file

@ -1,25 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PropTypes} from 'prop-types';
import React from 'react';
import {intlShape} from 'react-intl';
import {
Keyboard,
StyleSheet,
Text,
View,
} from 'react-native';
import {Keyboard, StyleSheet, Text, View} from 'react-native';
import Clipboard from '@react-native-community/clipboard';
import {PropTypes} from 'prop-types';
import FormattedText from 'app/components/formatted_text';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import BottomSheet from 'app/utils/bottom_sheet';
import {getDisplayNameForLanguage} from 'app/utils/markdown';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import mattermostManaged from 'app/mattermost_managed';
import {goToScreen} from 'app/actions/navigation';
import {goToScreen} from '@actions/navigation';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import mattermostManaged from '@mattermost-managed';
import BottomSheet from '@utils/bottom_sheet';
import {getDisplayNameForLanguage} from '@utils/markdown';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const MAX_LINES = 4;

View file

@ -15,7 +15,7 @@ export default class MarkdownEmoji extends PureComponent {
static propTypes = {
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
isEdited: PropTypes.bool,
shouldRenderJumboEmoji: PropTypes.bool.isRequired,
isJumboEmoji: PropTypes.bool.isRequired,
theme: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
};
@ -44,7 +44,7 @@ export default class MarkdownEmoji extends PureComponent {
};
computeTextStyle = (baseStyle) => {
if (!this.props.shouldRenderJumboEmoji) {
if (!this.props.isJumboEmoji) {
return baseStyle;
}

View file

@ -12,7 +12,7 @@ describe('MarkdownEmoji', () => {
const baseProps = {
baseTextStyle: {color: '#3d3c40', fontSize: 15, lineHeight: 20},
isEdited: false,
shouldRenderJumboEmoji: true,
isJumboEmoji: true,
theme: Preferences.THEMES.default,
value: ':smile:',
};

View file

@ -22,7 +22,8 @@ import TouchableWithFeedback from '@components/touchable_with_feedback';
import EphemeralStore from '@store/ephemeral_store';
import BottomSheet from '@utils/bottom_sheet';
import {generateId} from '@utils/file';
import {calculateDimensions, getViewPortWidth, isGifTooLarge, openGalleryAtIndex} from '@utils/images';
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
import {openGalleryAtIndex} from '@utils/gallery';
import {normalizeProtocol, tryOpenURL} from '@utils/url';
import mattermostManaged from 'app/mattermost_managed';

View file

@ -4,9 +4,10 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {handleSelectChannelByName} from '@actions/views/channel';
import {showPermalink} from '@actions/views/permalink';
import {getConfig, getCurrentUrl} from '@mm-redux/selectors/entities/general';
import {getCurrentTeam} from '@mm-redux/selectors/entities/teams';
import {handleSelectChannelByName} from 'app/actions/views/channel';
import MarkdownLink from './markdown_link';
@ -22,6 +23,7 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
handleSelectChannelByName,
showPermalink,
}, dispatch),
};
}

View file

@ -22,17 +22,16 @@ export default class MarkdownLink extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
handleSelectChannelByName: PropTypes.func.isRequired,
showPermalink: PropTypes.func.isRequired,
}).isRequired,
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]),
href: PropTypes.string.isRequired,
onPermalinkPress: PropTypes.func,
serverURL: PropTypes.string,
siteURL: PropTypes.string.isRequired,
currentTeamName: PropTypes.string,
};
static defaultProps = {
onPermalinkPress: () => true,
serverURL: '',
siteURL: '',
};
@ -42,7 +41,9 @@ export default class MarkdownLink extends PureComponent {
};
handlePress = preventDoubleTap(async () => {
const {href, onPermalinkPress, serverURL, siteURL} = this.props;
const {intl} = this.context;
const {actions, currentTeamName, href, serverURL, siteURL} = this.props;
const {handleSelectChannelByName, showPermalink} = actions;
const url = normalizeProtocol(href);
if (!url) {
@ -58,14 +59,10 @@ export default class MarkdownLink extends PureComponent {
if (match) {
if (match.type === DeepLinkTypes.CHANNEL) {
const {intl} = this.context;
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel.bind(null, intl), intl);
handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel, intl);
} else if (match.type === DeepLinkTypes.PERMALINK) {
if (match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
onPermalinkPress(match.postId, this.props.currentTeamName);
} else {
onPermalinkPress(match.postId, match.teamName);
}
const teamName = match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT ? currentTeamName : match.teamName;
showPermalink(intl, teamName, match.postId);
}
} else {
const onError = () => {

View file

@ -9,7 +9,8 @@ import CompassIcon from '@components/compass_icon';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import EphemeralStore from '@store/ephemeral_store';
import {calculateDimensions, isGifTooLarge, openGalleryAtIndex} from '@utils/images';
import {calculateDimensions, isGifTooLarge} from '@utils/images';
import {openGalleryAtIndex} from '@utils/gallery';
import {generateId} from '@utils/file';
import type {PostImage} from '@mm-redux/types/posts';

View file

@ -1,86 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import Button from 'react-native-button';
import ActionButtonText from './action_button_text';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {getStatusColors} from '@utils/message_attachment_colors';
import {Theme} from '@mm-redux/types/preferences';
import {ActionResult} from '@mm-redux/types/actions';
type Props = {
actions: {
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<ActionResult>;
};
id: string;
name: string;
postId: string;
theme: Theme,
cookie?: string,
disabled?: boolean,
buttonColor?: string,
}
export default class ActionButton extends PureComponent<Props> {
handleActionPress = preventDoubleTap(() => {
const {actions, id, postId, cookie} = this.props;
actions.doPostActionWithCookie(postId, id, cookie || '');
}, 4000);
render() {
const {name, theme, disabled, buttonColor} = this.props;
const style = getStyleSheet(theme);
let customButtonStyle;
let customButtonTextStyle;
if (buttonColor) {
const STATUS_COLORS = getStatusColors(theme);
const hexColor = STATUS_COLORS[buttonColor] || theme[buttonColor] || buttonColor;
customButtonStyle = {borderColor: changeOpacity(hexColor, 0.25), backgroundColor: '#ffffff'};
customButtonTextStyle = {color: hexColor};
}
return (
<Button
containerStyle={[style.button, customButtonStyle]}
disabledContainerStyle={style.buttonDisabled}
onPress={this.handleActionPress}
disabled={disabled}
>
<ActionButtonText
message={name}
style={{...style.text, ...customButtonTextStyle}}
/>
</Button>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const STATUS_COLORS = getStatusColors(theme);
return {
button: {
borderRadius: 4,
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
borderWidth: 2,
opacity: 1,
alignItems: 'center',
marginTop: 12,
justifyContent: 'center',
height: 36,
},
buttonDisabled: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
},
text: {
color: STATUS_COLORS.default,
fontSize: 15,
fontWeight: '600',
lineHeight: 17,
},
};
});

View file

@ -1,32 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import ActionButton from './action_button';
import {doPostActionWithCookie} from '@mm-redux/actions/posts';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {GlobalState} from '@mm-redux/types/store';
import {ActionFunc, ActionResult} from '@mm-redux/types/actions';
function mapStateToProps(state: GlobalState) {
return {
theme: getTheme(state),
};
}
type Actions = {
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string | undefined) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doPostActionWithCookie,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ActionButton);

View file

@ -1,55 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AttachmentImage it matches snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Array [
Object {
"marginTop": 5,
},
Object {
"width": 38,
},
]
}
type="none"
>
<View
style={
Array [
Object {
"borderColor": "rgba(61,60,64,0.1)",
"borderRadius": 2,
"borderWidth": 1,
"flex": 1,
},
Object {
"height": 28,
"width": 28,
},
]
}
>
<Connect(ProgressiveImage)
id="123"
imageStyle={
Object {
"marginBottom": 5,
"marginLeft": 2.5,
"marginRight": 5,
"marginTop": 2.5,
}
}
imageUri="https://images.com/image.png"
resizeMode="contain"
style={
Object {
"height": 28,
"width": 28,
}
}
/>
</View>
</TouchableWithFeedbackIOS>
`;

View file

@ -1,75 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Preferences from '@mm-redux/constants/preferences';
import AttachmentImage, {State, Props} from './index';
describe('AttachmentImage', () => {
const baseProps = {
deviceHeight: 256,
deviceWidth: 128,
imageMetadata: {width: 32, height: 32},
imageUrl: 'https://images.com/image.png',
theme: Preferences.THEMES.default,
} as Props;
test('it matches snapshot', () => {
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
test('it sets state based on props', () => {
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
const state = wrapper.state();
expect(state.hasImage).toBe(true);
expect(state.imageUri).toBe('https://images.com/image.png');
expect(state.originalWidth).toBe(32);
});
test('it does not render image if no imageUrl is provided', () => {
const props = {...baseProps};
delete props.imageUrl;
delete props.imageMetadata;
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...props}/>);
const state = wrapper.state();
expect(state.hasImage).toBe(false);
expect(state.imageUri).toBe(null);
});
test('it updates image when imageUrl prop changes', () => {
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
wrapper.setProps({
imageUrl: 'https://someothersite.com/picture.png',
imageMetadata: {
width: 96,
height: 96,
},
});
const state = wrapper.state();
expect(state.hasImage).toBe(true);
expect(state.imageUri).toBe('https://someothersite.com/picture.png');
expect(state.originalWidth).toBe(96);
});
test('it does not update image when an unrelated prop changes', () => {
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
wrapper.setProps({
theme: {...Preferences.THEMES.default},
});
const state = wrapper.state();
expect(state.hasImage).toBe(true);
expect(state.imageUri).toBe('https://images.com/image.png');
expect(state.originalWidth).toBe(32);
});
});

View file

@ -1,206 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {View} from 'react-native';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {generateId} from '@utils/file';
import {isGifTooLarge, openGalleryAtIndex, calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {Theme} from '@mm-redux/types/preferences';
import {PostImage} from '@mm-redux/types/posts';
import {FileInfo} from '@mm-redux/types/files';
const VIEWPORT_IMAGE_OFFSET = 100;
const VIEWPORT_IMAGE_CONTAINER_OFFSET = 10;
export type Props = {
deviceHeight: number;
deviceWidth: number;
imageMetadata?: PostImage;
imageUrl?: string;
postId?: string;
theme: Theme;
}
export type State = {
hasImage: boolean;
imageUri: string | null;
originalHeight?: number;
originalWidth?: number;
height?: number;
width?: number;
}
export default class AttachmentImage extends PureComponent<Props, State> {
private fileId: string;
private mounted = false;
private maxImageWidth = 0;
constructor(props: Props) {
super(props);
this.fileId = generateId();
this.state = {
hasImage: Boolean(props.imageUrl),
imageUri: null,
};
}
componentDidMount() {
this.mounted = true;
const {imageUrl, imageMetadata} = this.props;
this.setViewPortMaxWidth();
if (imageMetadata) {
this.setImageDimensionsFromMeta(null, imageMetadata);
}
if (imageUrl) {
this.setImageUrl(imageUrl);
}
}
componentDidUpdate(prevProps: Props) {
if (this.props.imageUrl && (prevProps.imageUrl !== this.props.imageUrl)) {
this.setImageUrl(this.props.imageUrl);
}
}
getFileInfo = () => {
const {imageUrl, postId} = this.props;
const {
imageUri: uri,
originalHeight,
originalWidth,
} = this.state;
if (!imageUrl) {
return {
id: this.fileId,
post_id: postId,
uri,
width: originalWidth,
height: originalHeight,
} as FileInfo;
}
let filename = imageUrl.substring(imageUrl.lastIndexOf('/') + 1, imageUrl.indexOf('?') === -1 ? imageUrl.length : imageUrl.indexOf('?'));
const extension = filename.split('.').pop();
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
}
const out = {
id: this.fileId,
name: filename,
extension,
has_preview_image: true,
post_id: postId,
uri,
width: originalWidth,
height: originalHeight,
} as FileInfo;
return out;
}
handlePreviewImage = () => {
const files = [this.getFileInfo()];
openGalleryAtIndex(0, files);
};
setImageDimensions = (imageUri: string | null, dimensions: {width?: number; height?: number;}, originalWidth: number, originalHeight: number) => {
if (this.mounted) {
this.setState({
...dimensions,
originalWidth,
originalHeight,
imageUri,
});
}
};
setImageDimensionsFromMeta = (imageUri: string | null, imageMetadata: PostImage) => {
const dimensions = calculateDimensions(imageMetadata.height, imageMetadata.width, this.maxImageWidth);
this.setImageDimensions(imageUri, dimensions, imageMetadata.width, imageMetadata.height);
};
setImageUrl = (imageURL: string) => {
const {imageMetadata} = this.props;
if (imageMetadata) {
this.setImageDimensionsFromMeta(imageURL, imageMetadata);
}
};
setViewPortMaxWidth = () => {
const {deviceWidth, deviceHeight} = this.props;
const viewPortWidth = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
this.maxImageWidth = viewPortWidth - VIEWPORT_IMAGE_OFFSET;
};
render() {
const {imageMetadata, theme} = this.props;
const {hasImage, height, imageUri, width} = this.state;
if (!hasImage || isGifTooLarge(imageMetadata)) {
return null;
}
const style = getStyleSheet(theme);
let progressiveImage;
if (imageUri) {
progressiveImage = (
<ProgressiveImage
id={this.fileId}
imageStyle={style.attachmentMargin}
style={{height, width}}
imageUri={imageUri}
resizeMode='contain'
/>
);
} else {
progressiveImage = (<View style={{width, height}}/>);
}
return (
<TouchableWithFeedback
onPress={this.handlePreviewImage}
style={[style.container, {width: this.maxImageWidth + VIEWPORT_IMAGE_CONTAINER_OFFSET}]}
type={'none'}
>
<View
style={[style.imageContainer, {width, height}]}
>
{progressiveImage}
</View>
</TouchableWithFeedback>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 5,
},
imageContainer: {
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
borderWidth: 1,
borderRadius: 2,
flex: 1,
},
attachmentMargin: {
marginTop: 2.5,
marginLeft: 2.5,
marginBottom: 5,
marginRight: 5,
},
};
});

View file

@ -1,136 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {LayoutChangeEvent, ScrollView, StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native';
import Markdown from '@components/markdown';
import ShowMoreButton from '@components/show_more_button';
import {PostMetadata} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/preferences';
const SHOW_MORE_HEIGHT = 60;
type Props = {
baseTextStyle: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
hasThumbnail?: boolean,
metadata?: PostMetadata,
onPermalinkPress?: () => void,
textStyles?: StyleProp<TextStyle>[],
theme?: Theme,
value?: string,
}
type State = {
collapsed: boolean;
isLongText: boolean;
maxHeight: number;
}
function getMaxHeight(deviceHeight: number) {
return Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
}
export default class AttachmentText extends PureComponent<Props, State> {
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
const {deviceHeight} = nextProps;
const maxHeight = getMaxHeight(deviceHeight);
if (maxHeight !== prevState.maxHeight) {
return {
maxHeight,
};
}
return null;
}
constructor(props: Props) {
super(props);
const maxHeight = getMaxHeight(props.deviceHeight);
this.state = {
collapsed: true,
isLongText: false,
maxHeight,
};
}
handleLayout = (event: LayoutChangeEvent) => {
const {height} = event.nativeEvent.layout;
const {maxHeight} = this.state;
if (height >= maxHeight) {
this.setState({
isLongText: true,
});
}
};
toggleCollapseState = () => {
const {collapsed} = this.state;
this.setState({collapsed: !collapsed});
};
render() {
const {
baseTextStyle,
blockStyles,
hasThumbnail,
metadata,
onPermalinkPress,
textStyles,
theme,
value,
} = this.props;
const {collapsed, isLongText, maxHeight} = this.state;
if (!value) {
return null;
}
return (
<View style={hasThumbnail && style.container}>
<ScrollView
style={{maxHeight: (collapsed ? maxHeight : undefined), overflow: 'hidden'}}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
>
<View
onLayout={this.handleLayout}
removeClippedSubviews={isLongText && collapsed}
>
<Markdown
// TODO: remove any when migrating Markdown to typescript
baseTextStyle={baseTextStyle as any}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
value={value}
onPermalinkPress={onPermalinkPress}
/>
</View>
</ScrollView>
{isLongText &&
<ShowMoreButton
onPress={this.toggleCollapseState}
showMore={collapsed}
theme={theme}
/>
}
</View>
);
}
}
const style = StyleSheet.create({
container: {
paddingRight: 60,
},
});

View file

@ -1,114 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {Alert, Text, View} from 'react-native';
import {intlShape} from 'react-intl';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
import {Theme} from '@mm-redux/types/preferences';
type Props = {
link?: string;
theme: Theme;
value?: string;
}
export default class AttachmentTitle extends PureComponent<Props> {
static contextTypes = {
intl: intlShape.isRequired,
};
openLink = () => {
const {link} = this.props;
const {intl} = this.context;
if (link) {
const onError = () => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
};
tryOpenURL(link, onError);
}
};
render() {
const {
link,
value,
theme,
} = this.props;
if (!value) {
return null;
}
const style = getStyleSheet(theme);
let title;
if (link) {
title = (
<Text
style={[style.title, Boolean(link) && style.link]}
onPress={this.openLink}
>
{value}
</Text>
);
} else {
title = (
<Markdown
isEdited={false}
isReplyPost={false}
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
autolinkedUrlSchemes={[]}
mentionKeys={[]}
theme={theme}
value={value}
baseTextStyle={style.title}
textStyles={{link: style.link}}
/>
);
}
return (
<View style={style.container}>
{title}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 3,
flex: 1,
flexDirection: 'row',
},
title: {
color: theme.centerChannelColor,
fontWeight: '600',
marginBottom: 5,
fontSize: 14,
lineHeight: 20,
},
link: {
color: theme.linkColor,
},
};
});

View file

@ -1,64 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import {MessageAttachment as MessageAttachmentType} from '@mm-redux/types/message_attachments';
import {PostMetadata} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/preferences';
import MessageAttachment from './message_attachment';
type Props = {
attachments: MessageAttachmentType[],
baseTextStyle?: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
deviceWidth: number,
postId: string,
metadata?: PostMetadata,
onPermalinkPress?: () => void,
theme: Theme,
textStyles?: StyleProp<TextStyle>[],
}
export default function MessageAttachments(props: Props) {
const {
attachments,
baseTextStyle,
blockStyles,
deviceHeight,
deviceWidth,
metadata,
onPermalinkPress,
postId,
theme,
textStyles,
} = props;
const content = [] as React.ReactNode[];
attachments.forEach((attachment, i) => {
content.push(
<MessageAttachment
attachment={attachment}
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
key={'att_' + i}
metadata={metadata}
onPermalinkPress={onPermalinkPress}
postId={postId}
theme={theme}
textStyles={textStyles}
/>,
);
});
return (
<View style={{flex: 1, flexDirection: 'column'}}>
{content}
</View>
);
}

View file

@ -1,71 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AttachmentFooter matches snapshot 1`] = `
<ForwardRef(AnimatedComponentWrapper)
pointerEvents="none"
style={
Array [
Object {
"height": 38,
"position": "absolute",
"width": "100%",
"zIndex": 9,
},
Object {
"backgroundColor": "rgba(147, 147, 147, 1)",
"opacity": 0,
"top": 50,
},
]
}
>
<ForwardRef(AnimatedComponentWrapper)
edges={
Array [
"left",
"right",
]
}
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 38,
"paddingLeft": 12,
"paddingRight": 5,
}
}
>
<FormattedText
defaultMessage="Connected"
id="mobile.offlineIndicator.connected"
style={
Object {
"color": "#FFFFFF",
"flex": 1,
"fontSize": 12,
"fontWeight": "600",
}
}
/>
<View
style={
Object {
"alignItems": "flex-end",
"height": 24,
"justifyContent": "center",
"paddingRight": 10,
"width": 60,
}
}
>
<CompassIcon
color="#FFFFFF"
name="check"
size={20}
/>
</View>
</ForwardRef(AnimatedComponentWrapper)>
</ForwardRef(AnimatedComponentWrapper)>
`;

View file

@ -1,47 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {stopPeriodicStatusUpdates, startPeriodicStatusUpdates} from '@mm-redux/actions/users';
import {init as initWebSocket, close as closeWebSocket} from '@actions/websocket';
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
import {connection} from 'app/actions/device';
import {markChannelViewedAndReadOnReconnect, setChannelRetryFailed} from 'app/actions/views/channel';
import {setCurrentUserStatusOffline, logout} from 'app/actions/views/user';
import {getConnection, isLandscape} from 'app/selectors/device';
import NetworkIndicator from './network_indicator';
function mapStateToProps(state) {
const {websocket} = state.requests.general;
const websocketStatus = websocket.status;
return {
currentChannelId: getCurrentChannelId(state),
isLandscape: isLandscape(state),
isOnline: getConnection(state),
websocketErrorCount: websocket.error,
websocketStatus,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
closeWebSocket,
connection,
initWebSocket,
logout,
markChannelViewedAndReadOnReconnect,
setChannelRetryFailed,
setCurrentUserStatusOffline,
startPeriodicStatusUpdates,
stopPeriodicStatusUpdates,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(NetworkIndicator);

View file

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {stopPeriodicStatusUpdates, startPeriodicStatusUpdates} from '@mm-redux/actions/users';
import {init as initWebSocket, close as closeWebSocket} from '@actions/websocket';
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
import {markChannelViewedAndReadOnReconnect} from 'app/actions/views/channel';
import {setCurrentUserStatusOffline} from 'app/actions/views/user';
import type {GlobalState} from '@mm-redux/types/store';
import NetworkIndicator from './network';
function mapStateToProps(state: GlobalState) {
const {websocket} = state.requests.general;
return {
channelId: getCurrentChannelId(state),
errorCount: websocket.error,
status: websocket.status,
};
}
const mapDispatchToProps = {
closeWebSocket,
initWebSocket,
markChannelViewedAndReadOnReconnect,
setCurrentUserStatusOffline,
startPeriodicStatusUpdates,
stopPeriodicStatusUpdates,
};
export default connect(mapStateToProps, mapDispatchToProps)(NetworkIndicator);

View file

@ -0,0 +1,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;

View file

@ -1,432 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
ActivityIndicator,
Animated,
AppState,
Platform,
StyleSheet,
View,
} from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import {SafeAreaView} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {ViewTypes} from '@constants';
import {INDICATOR_BAR_HEIGHT} from '@constants/view';
import PushNotifications from '@init/push_notifications';
import {debounce} from '@mm-redux/actions/helpers';
import {RequestStatus} from '@mm-redux/constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {t} from '@utils/i18n';
import networkConnectionListener, {checkConnection} from '@utils/network';
const MAX_WEBSOCKET_RETRIES = 3;
const CONNECTION_RETRY_SECONDS = 5;
const CONNECTION_RETRY_TIMEOUT = 1000 * CONNECTION_RETRY_SECONDS; // 30 seconds
const {
ANDROID_TOP_LANDSCAPE,
ANDROID_TOP_PORTRAIT,
IOS_TOP_LANDSCAPE,
IOS_INSETS_TOP_PORTRAIT,
} = ViewTypes;
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
export default class NetworkIndicator extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
closeWebSocket: PropTypes.func.isRequired,
connection: PropTypes.func.isRequired,
initWebSocket: PropTypes.func.isRequired,
markChannelViewedAndReadOnReconnect: PropTypes.func.isRequired,
logout: PropTypes.func.isRequired,
setChannelRetryFailed: PropTypes.func.isRequired,
setCurrentUserStatusOffline: PropTypes.func.isRequired,
startPeriodicStatusUpdates: PropTypes.func.isRequired,
stopPeriodicStatusUpdates: PropTypes.func.isRequired,
}).isRequired,
currentChannelId: PropTypes.string,
isLandscape: PropTypes.bool,
isOnline: PropTypes.bool,
websocketErrorCount: PropTypes.number,
websocketStatus: PropTypes.string,
};
static defaultProps = {
isOnline: true,
};
constructor(props) {
super(props);
const navBarHeight = Platform.select({
android: props.isLandscape ? ANDROID_TOP_LANDSCAPE : ANDROID_TOP_PORTRAIT,
ios: props.isLandscape ? IOS_TOP_LANDSCAPE : IOS_INSETS_TOP_PORTRAIT,
});
this.state = {
opacity: 0,
navBarHeight,
};
this.top = new Animated.Value(navBarHeight - INDICATOR_BAR_HEIGHT);
this.clearNotificationTimeout = null;
this.backgroundColor = new Animated.Value(0);
this.firstRun = true;
this.statusUpdates = false;
this.networkListener = networkConnectionListener(this.handleConnectionChange);
}
componentDidMount() {
this.mounted = true;
AppState.addEventListener('change', this.handleAppStateChange);
EventEmitter.on(ViewTypes.CHANNEL_NAV_BAR_CHANGED, this.getNavBarHeight);
// Attempt to connect when this component mounts
// if the websocket is already connected it does not try and connect again
this.connect(true);
}
componentDidUpdate(prevProps, prevState) {
const {
currentChannelId: prevChannelId,
websocketStatus: previousWebsocketStatus,
} = prevProps;
const {currentChannelId, websocketErrorCount, websocketStatus} = this.props;
if (currentChannelId !== prevChannelId && this.clearNotificationTimeout) {
clearTimeout(this.clearNotificationTimeout);
this.clearNotificationTimeout = null;
}
if (prevState.navBarHeight !== this.state.navBarHeight) {
const initialTop = websocketErrorCount || previousWebsocketStatus === RequestStatus.FAILURE || previousWebsocketStatus === RequestStatus.NOT_STARTED ? 0 : INDICATOR_BAR_HEIGHT;
this.top.setValue(this.state.navBarHeight - initialTop);
}
if (this.props.isOnline) {
if (previousWebsocketStatus !== RequestStatus.SUCCESS && websocketStatus === RequestStatus.SUCCESS) {
// Show the connected animation only if we had a previous network status
this.connected();
clearTimeout(this.connectionRetryTimeout);
} else if (previousWebsocketStatus === RequestStatus.STARTED && websocketStatus === RequestStatus.FAILURE && websocketErrorCount > MAX_WEBSOCKET_RETRIES) {
this.handleWebSocket(false);
this.handleReconnect();
} else if (websocketStatus === RequestStatus.FAILURE) {
this.show();
}
} else {
this.offline();
}
}
componentWillUnmount() {
const {closeWebSocket, stopPeriodicStatusUpdates} = this.props.actions;
this.mounted = false;
closeWebSocket(false);
stopPeriodicStatusUpdates();
this.networkListener.removeEventListener();
AppState.removeEventListener('change', this.handleAppStateChange);
EventEmitter.off(ViewTypes.CHANNEL_NAV_BAR_CHANGED, this.getNavBarHeight);
clearTimeout(this.connectionRetryTimeout);
this.connectionRetryTimeout = null;
}
connect = (displayBar = false) => {
const {connection, startPeriodicStatusUpdates} = this.props.actions;
clearTimeout(this.connectionRetryTimeout);
NetInfo.fetch().then(async ({isConnected}) => {
const {hasInternet, serverReachable} = await checkConnection(isConnected);
connection(hasInternet);
this.hasInternet = hasInternet;
this.serverReachable = serverReachable;
if (serverReachable) {
this.statusUpdates = true;
this.initializeWebSocket();
startPeriodicStatusUpdates();
} else {
if (displayBar) {
this.show();
}
this.handleWebSocket(false);
if (hasInternet) {
// try to reconnect cause we have internet
this.handleReconnect();
}
}
});
};
connected = () => {
if (this.visible) {
this.visible = false;
Animated.sequence([
Animated.timing(
this.backgroundColor, {
toValue: 1,
duration: 100,
useNativeDriver: false,
},
),
Animated.timing(
this.top, {
toValue: (this.state.navBarHeight - INDICATOR_BAR_HEIGHT),
duration: 300,
delay: 500,
useNativeDriver: false,
},
),
]).start(() => {
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, false);
this.backgroundColor.setValue(0);
this.setState({
opacity: 0,
});
});
}
};
getNavBarHeight = (navBarHeight) => {
this.setState({navBarHeight});
};
handleWebSocket = (open) => {
const {actions} = this.props;
const {
closeWebSocket,
startPeriodicStatusUpdates,
stopPeriodicStatusUpdates,
} = actions;
if (open) {
this.statusUpdates = true;
this.initializeWebSocket();
startPeriodicStatusUpdates();
} else if (this.statusUpdates) {
this.statusUpdates = false;
closeWebSocket(true);
stopPeriodicStatusUpdates();
}
};
handleAppStateChange = async (appState) => {
this.onStateChange(appState);
};
handleConnectionChange = ({hasInternet, serverReachable}) => {
const {connection} = this.props.actions;
// On first run always initialize the WebSocket
// if we have internet connection
if (hasInternet && this.firstRun) {
this.initializeWebSocket();
this.firstRun = false;
// if the state of the internet connection was previously known to be false,
// don't exit connection handler in order for application to register it has
// reconnected to the internet
if (this.hasInternet !== false) {
return;
}
}
// Prevent for being called more than once.
if (this.hasInternet !== hasInternet) {
this.hasInternet = hasInternet;
connection(hasInternet);
}
if (this.serverReachable !== serverReachable) {
this.serverReachable = serverReachable;
this.handleWebSocket(serverReachable);
}
};
handleReconnect = () => {
clearTimeout(this.connectionRetryTimeout);
this.connectionRetryTimeout = setTimeout(() => {
const {websocketStatus} = this.props;
if (websocketStatus !== RequestStatus.STARTED || websocketStatus !== RequestStatus.SUCCESS) {
this.connect();
}
}, CONNECTION_RETRY_TIMEOUT);
};
initializeWebSocket = async () => {
const {actions} = this.props;
const {closeWebSocket, initWebSocket} = actions;
initWebSocket({forceConnection: true}).catch(() => {
// we should dispatch a failure and show the app as disconnected
closeWebSocket(true);
});
};
offline = () => {
if (this.connectionRetryTimeout) {
clearTimeout(this.connectionRetryTimeout);
}
this.show();
};
onStateChange = debounce((appState) => {
const {actions, currentChannelId} = this.props;
const active = appState === 'active';
if (active) {
this.connect(true);
if (currentChannelId) {
// Clear the notifications for the current channel after one second
// this is done so we can cancel it in case the app is brought to the
// foreground by tapping a notification from another channel
this.clearNotificationTimeout = setTimeout(() => {
PushNotifications.clearChannelNotifications(currentChannelId);
actions.markChannelViewedAndReadOnReconnect(currentChannelId);
}, 1000);
}
} else {
this.handleWebSocket(false);
}
}, 300);
show = () => {
if (!this.visible) {
this.visible = true;
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, true);
this.setState({
opacity: 1,
});
Animated.timing(
this.top, {
toValue: this.state.navBarHeight,
duration: 300,
useNativeDriver: false,
},
).start(() => {
this.props.actions.setCurrentUserStatusOffline();
});
}
};
render() {
const {isOnline, websocketStatus} = this.props;
const background = this.backgroundColor.interpolate({
inputRange: [0, 1],
outputRange: ['#939393', '#629a41'],
});
let i18nId;
let defaultMessage;
let action;
if (isOnline) {
switch (websocketStatus) {
case RequestStatus.NOT_STARTED:
case RequestStatus.FAILURE:
case RequestStatus.STARTED:
i18nId = t('mobile.offlineIndicator.connecting');
defaultMessage = 'Connecting...';
action = (
<View style={styles.actionContainer}>
<ActivityIndicator
color='#FFFFFF'
size='small'
/>
</View>
);
break;
case RequestStatus.SUCCESS:
default:
i18nId = t('mobile.offlineIndicator.connected');
defaultMessage = 'Connected';
action = (
<View style={styles.actionContainer}>
<CompassIcon
color='#FFFFFF'
name='check'
size={20}
/>
</View>
);
break;
}
} else {
i18nId = t('mobile.offlineIndicator.offline');
defaultMessage = 'No internet connection';
}
return (
<Animated.View
pointerEvents='none'
style={[styles.container, {top: this.top, backgroundColor: background, opacity: this.state.opacity}]}
>
<AnimatedSafeAreaView
edges={['left', 'right']}
style={styles.wrapper}
>
<FormattedText
defaultMessage={defaultMessage}
id={i18nId}
style={styles.message}
/>
{action}
</AnimatedSafeAreaView>
</Animated.View>
);
}
}
const styles = StyleSheet.create({
container: {
height: INDICATOR_BAR_HEIGHT,
width: '100%',
position: 'absolute',
...Platform.select({
android: {
elevation: 9,
},
ios: {
zIndex: 9,
},
}),
},
wrapper: {
alignItems: 'center',
flex: 1,
height: INDICATOR_BAR_HEIGHT,
flexDirection: 'row',
paddingLeft: 12,
paddingRight: 5,
},
message: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
flex: 1,
},
actionContainer: {
alignItems: 'flex-end',
height: 24,
justifyContent: 'center',
paddingRight: 10,
width: 60,
},
});

View file

@ -1,78 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Animated} from 'react-native';
import {shallow} from 'enzyme';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {ViewTypes} from '@constants';
import NetworkIndicator from './network_indicator';
jest.useFakeTimers();
describe('AttachmentFooter', () => {
Animated.sequence = jest.fn(() => ({
start: jest.fn((cb) => cb()),
}));
Animated.timing = jest.fn(() => ({
start: jest.fn((cb) => cb()),
}));
const baseProps = {
actions: {
closeWebSocket: jest.fn(),
connection: jest.fn(),
initWebSocket: jest.fn(),
markChannelViewedAndReadOnReconnect: jest.fn(),
logout: jest.fn(),
setChannelRetryFailed: jest.fn(),
setCurrentUserStatusOffline: jest.fn(),
startPeriodicStatusUpdates: jest.fn(),
stopPeriodicStatusUpdates: jest.fn(),
},
};
it('matches snapshot', () => {
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
describe('show', () => {
EventEmitter.emit = jest.fn();
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
const instance = wrapper.instance();
it('emits INDICATOR_BAR_VISIBLE with true only if not already visible', async () => {
instance.visible = true;
instance.show();
expect(EventEmitter.emit).not.toHaveBeenCalled();
instance.visible = false;
instance.show();
expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, true);
expect(instance.visible).toBe(true);
expect(wrapper.state('opacity')).toBe(1);
});
});
describe('connected', () => {
EventEmitter.emit = jest.fn();
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
const instance = wrapper.instance();
it('emits INDICATOR_BAR_VISIBLE with false only if visible', async () => {
instance.visible = false;
instance.connected();
expect(EventEmitter.emit).not.toHaveBeenCalled();
instance.visible = true;
instance.connected();
expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, false);
expect(instance.visible).toBe(false);
expect(wrapper.state('opacity')).toBe(0);
});
});
});

View file

@ -1,105 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {createPost, removePost} from '@mm-redux/actions/posts';
import {Posts} from '@mm-redux/constants';
import {isChannelReadOnlyById} from '@mm-redux/selectors/entities/channels';
import {getPost, makeGetCommentCountForPost, makeIsPostCommentMention} from '@mm-redux/selectors/entities/posts';
import {getUser, getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {getMyPreferences, getTheme} from '@mm-redux/selectors/entities/preferences';
import {isDateLine, isStartOfNewMessages} from '@mm-redux/utils/post_list';
import {isPostFlagged, isSystemMessage} from '@mm-redux/utils/post_utils';
import {insertToDraft, setPostTooltipVisible} from 'app/actions/views/channel';
import Post from './post';
function isConsecutivePost(post, previousPost) {
let consecutivePost = false;
if (post && previousPost) {
const postFromWebhook = Boolean(post?.props?.from_webhook); // eslint-disable-line camelcase
const prevPostFromWebhook = Boolean(previousPost?.props?.from_webhook); // eslint-disable-line camelcase
if (previousPost && previousPost.user_id === post.user_id &&
post.create_at - previousPost.create_at <= Posts.POST_COLLAPSE_TIMEOUT &&
!postFromWebhook && !prevPostFromWebhook &&
!isSystemMessage(post) && !isSystemMessage(previousPost) &&
(previousPost.root_id === post.root_id || previousPost.id === post.root_id)) {
// The last post and this post were made by the same user within some time
consecutivePost = true;
}
}
return consecutivePost;
}
function makeMapStateToProps() {
const getCommentCountForPost = makeGetCommentCountForPost();
const isPostCommentMention = makeIsPostCommentMention();
return function mapStateToProps(state, ownProps) {
const post = ownProps.post || getPost(state, ownProps.postId);
const previousPostId = (isStartOfNewMessages(ownProps.previousPostId) || isDateLine(ownProps.previousPostId)) ? ownProps.beforePrevPostId : ownProps.previousPostId;
const previousPost = getPost(state, previousPostId);
const beforePrevPost = getPost(state, ownProps.beforePrevPostId);
const myPreferences = getMyPreferences(state);
const currentUserId = getCurrentUserId(state);
const user = getUser(state, post.user_id);
const isCommentMention = isPostCommentMention(state, post.id);
let isFirstReply = true;
let isLastReply = true;
let commentedOnPost = null;
if (ownProps.renderReplies && post && post.root_id) {
if (previousPostId) {
if (previousPost && (previousPost.id === post.root_id || previousPost.root_id === post.root_id)) {
// Previous post is root post or previous post is in same thread
isFirstReply = false;
} else {
// Last post is not a comment on the same message
commentedOnPost = getPost(state, post.root_id);
}
}
if (ownProps.nextPostId) {
const nextPost = getPost(state, ownProps.nextPostId);
if (nextPost && nextPost.root_id === post.root_id) {
isLastReply = false;
}
}
}
return {
channelIsReadOnly: isChannelReadOnlyById(state, post.channel_id),
currentUserId,
post,
isBot: (user ? user.is_bot : false),
isFirstReply,
isLastReply,
consecutivePost: isConsecutivePost(post, previousPost),
hasComments: getCommentCountForPost(state, {post}) > 0,
commentedOnPost,
theme: getTheme(state),
isFlagged: isPostFlagged(post.id, myPreferences),
isCommentMention,
previousPostExists: Boolean(previousPost),
beforePrevPostUserId: (beforePrevPost ? beforePrevPost.user_id : null),
};
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
createPost,
removePost,
setPostTooltipVisible,
insertToDraft,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(Post);

View file

@ -1,452 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Keyboard,
Platform,
View,
ViewPropTypes,
} from 'react-native';
import {intlShape} from 'react-intl';
import {Posts} from '@mm-redux/constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from '@mm-redux/utils/post_utils';
import {showModalOverCurrentContext, showModal} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
import PostBody from '@components/post_body';
import PostHeader from '@components/post_header';
import PostProfilePicture from '@components/post_profile_picture';
import PostPreHeader from '@components/post_header/post_pre_header';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {NavigationTypes} from '@constants';
import {t} from '@utils/i18n';
import {preventDoubleTap} from '@utils/tap';
import {fromAutoResponder} from '@utils/general';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
export default class Post extends PureComponent {
static propTypes = {
testID: PropTypes.string,
actions: PropTypes.shape({
createPost: PropTypes.func.isRequired,
insertToDraft: PropTypes.func.isRequired,
removePost: PropTypes.func.isRequired,
}).isRequired,
channelIsReadOnly: PropTypes.bool,
currentUserId: PropTypes.string.isRequired,
highlight: PropTypes.bool,
style: ViewPropTypes.style,
post: PropTypes.object,
renderReplies: PropTypes.bool,
isFirstReply: PropTypes.bool,
isLastReply: PropTypes.bool,
isLastPost: PropTypes.bool,
consecutivePost: PropTypes.bool,
hasComments: PropTypes.bool,
isSearchResult: PropTypes.bool,
commentedOnPost: PropTypes.object,
managedConfig: PropTypes.object,
onHashtagPress: PropTypes.func,
onPermalinkPress: PropTypes.func,
shouldRenderReplyButton: PropTypes.bool,
showAddReaction: PropTypes.bool,
showFullDate: PropTypes.bool,
showLongPost: PropTypes.bool,
theme: PropTypes.object.isRequired,
onPress: PropTypes.func,
onReply: PropTypes.func,
isFlagged: PropTypes.bool,
highlightPinnedOrFlagged: PropTypes.bool,
skipFlaggedHeader: PropTypes.bool,
skipPinnedHeader: PropTypes.bool,
isCommentMention: PropTypes.bool,
location: PropTypes.string,
isBot: PropTypes.bool,
previousPostExists: PropTypes.bool,
beforePrevPostUserId: PropTypes.string,
};
static defaultProps = {
isSearchResult: false,
showAddReaction: true,
showLongPost: false,
channelIsReadOnly: false,
highlightPinnedOrFlagged: true,
};
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props) {
super(props);
this.postBodyRef = React.createRef();
}
goToUserProfile = async () => {
const {intl} = this.context;
const {post, theme} = this.props;
const screen = 'UserProfile';
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const passProps = {
userId: post.user_id,
};
if (!this.closeButton) {
this.closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
}
const options = {
topBar: {
leftButtons: [{
id: 'close-settings',
icon: this.closeButton,
testID: 'close.settings.button',
}],
},
};
Keyboard.dismiss();
showModal(screen, title, passProps, options);
};
autofillUserMention = (username) => {
this.props.actions.insertToDraft(`@${username} `);
};
handleFailedPostPress = () => {
const screen = 'OptionsModal';
const passProps = {
title: {
id: t('mobile.post.failed_title'),
defaultMessage: 'Unable to send your message:',
},
items: [{
action: () => {
const {failed, id, ...post} = this.props.post; // eslint-disable-line
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
this.props.actions.createPost(post);
},
text: {
id: t('mobile.post.failed_retry'),
defaultMessage: 'Try Again',
},
}, {
action: () => {
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
this.onRemovePost(this.props.post);
},
text: {
id: t('mobile.post.failed_delete'),
defaultMessage: 'Delete Message',
},
textStyle: {
color: '#CC3239',
},
}],
};
showModalOverCurrentContext(screen, passProps);
};
handlePress = preventDoubleTap(() => {
this.onPressDetected = true;
const {
onPress,
post,
showLongPost,
} = this.props;
const isValidSystemMessage = fromAutoResponder(post) || !isSystemMessage(post);
if (onPress && post.state !== Posts.POST_DELETED && isValidSystemMessage && !isPostPendingOrFailed(post)) {
onPress(post);
} else if ((isPostEphemeral(post) || post.state === Posts.POST_DELETED) && !showLongPost) {
this.onRemovePost(post);
}
setTimeout(() => {
this.onPressDetected = false;
}, 300);
});
handleReply = preventDoubleTap(() => {
const {post, onReply} = this.props;
if (onReply) {
return onReply(post);
}
return this.handlePress();
});
onRemovePost = (post) => {
const {removePost} = this.props.actions;
removePost(post);
};
isReplyPost = () => {
const {renderReplies, post} = this.props;
return Boolean(renderReplies && post.root_id && (!isPostEphemeral(post) || post.state === Posts.POST_DELETED));
};
replyBarStyle = () => {
const {
commentedOnPost,
isFirstReply,
isLastReply,
theme,
isCommentMention,
} = this.props;
if (!this.isReplyPost()) {
return null;
}
const style = getStyleSheet(theme);
const replyBarStyle = [style.replyBar];
if (isFirstReply || commentedOnPost) {
replyBarStyle.push(style.replyBarFirst);
}
if (isLastReply) {
replyBarStyle.push(style.replyBarLast);
}
if (isCommentMention) {
replyBarStyle.push(style.commentMentionBgColor);
}
return replyBarStyle;
};
viewUserProfile = preventDoubleTap(() => {
this.goToUserProfile();
});
showPostOptions = () => {
if (this.postBodyRef?.current && !this.onPressDetected) {
this.postBodyRef.current.showPostOptions();
}
};
render() {
const {
testID,
channelIsReadOnly,
commentedOnPost,
highlight,
isLastPost,
isLastReply,
isSearchResult,
onHashtagPress,
onPermalinkPress,
post,
isBot,
renderReplies,
shouldRenderReplyButton,
showAddReaction,
showFullDate,
showLongPost,
theme,
managedConfig,
consecutivePost,
hasComments,
isFlagged,
highlightPinnedOrFlagged,
skipFlaggedHeader,
skipPinnedHeader,
location,
previousPostExists,
beforePrevPostUserId,
} = this.props;
if (!post) {
return null;
}
const style = getStyleSheet(theme);
const isReplyPost = this.isReplyPost();
const mergeMessage = consecutivePost && !hasComments && !isBot;
const highlightFlagged = isFlagged && !skipFlaggedHeader;
const hightlightPinned = post.is_pinned && !skipPinnedHeader;
let highlighted;
if (highlight) {
highlighted = style.highlight;
} else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrFlagged) {
highlighted = style.highlightPinnedOrFlagged;
}
let postHeader;
let userProfile;
let consecutiveStyle;
if (mergeMessage) {
consecutiveStyle = {marginTop: 0};
userProfile = <View style={style.consecutivePostContainer}/>;
} else {
userProfile = (
<View style={[style.profilePictureContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
<PostProfilePicture
onViewUserProfile={this.viewUserProfile}
post={post}
/>
</View>
);
postHeader = (
<PostHeader
post={post}
commentedOnPost={commentedOnPost}
createAt={post.create_at}
isSearchResult={isSearchResult}
shouldRenderReplyButton={shouldRenderReplyButton}
showFullDate={showFullDate}
onPress={this.handleReply}
onUsernamePress={this.viewUserProfile}
renderReplies={renderReplies}
theme={theme}
previousPostExists={previousPostExists}
beforePrevPostUserId={beforePrevPostUserId}
/>
);
}
const replyBarStyle = this.replyBarStyle();
const rightColumnStyle = [style.rightColumn, (commentedOnPost && isLastReply && style.rightColumnPadding)];
const itemTestID = `${testID}.${post.id}`;
return (
<View
testID={testID}
style={[style.postStyle, highlighted]}
>
<TouchableWithFeedback
testID={itemTestID}
onPress={this.handlePress}
onLongPress={this.showPostOptions}
delayLongPress={200}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
cancelTouchOnPanning={true}
>
<>
<PostPreHeader
isConsecutive={mergeMessage}
isFlagged={isFlagged}
isPinned={post.is_pinned}
rightColumnStyle={style.preHeaderRightColumn}
skipFlaggedHeader={skipFlaggedHeader}
skipPinnedHeader={skipPinnedHeader}
theme={theme}
/>
<View style={[style.container, this.props.style, consecutiveStyle]}>
{userProfile}
<View style={rightColumnStyle}>
{postHeader}
<PostBody
ref={this.postBodyRef}
highlight={highlight}
channelIsReadOnly={channelIsReadOnly}
isLastPost={isLastPost}
isSearchResult={isSearchResult}
onFailedPostPress={this.handleFailedPostPress}
onHashtagPress={onHashtagPress}
onPermalinkPress={onPermalinkPress}
onPress={this.handlePress}
post={post}
replyBarStyle={replyBarStyle}
managedConfig={managedConfig}
isFlagged={isFlagged}
isReplyPost={isReplyPost}
showAddReaction={showAddReaction}
showLongPost={showLongPost}
location={location}
/>
</View>
</View>
</>
</TouchableWithFeedback>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
postStyle: {
overflow: 'hidden',
flex: 1,
},
container: {
flexDirection: 'row',
},
pendingPost: {
opacity: 0.5,
},
preHeaderRightColumn: {
flex: 1,
flexDirection: 'column',
marginLeft: 2,
},
rightColumn: {
flex: 1,
flexDirection: 'column',
marginRight: 12,
},
rightColumnPadding: {
paddingBottom: 3,
},
consecutivePostContainer: {
marginBottom: 10,
marginRight: 10,
marginLeft: 47,
marginTop: 10,
},
profilePictureContainer: {
marginBottom: 5,
marginLeft: 12,
marginTop: 10,
// to compensate STATUS_BUFFER in profile_picture component
...Platform.select({
android: {
marginRight: 11,
},
ios: {
marginRight: 10,
},
}),
},
replyBar: {
backgroundColor: theme.centerChannelColor,
opacity: 0.1,
marginLeft: 1,
marginRight: 7,
width: 3,
flexBasis: 3,
},
replyBarFirst: {
paddingTop: 10,
},
replyBarLast: {
paddingBottom: 10,
},
commentMentionBgColor: {
backgroundColor: theme.mentionHighlightBg,
opacity: 1,
},
highlight: {
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5),
},
highlightPinnedOrFlagged: {
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2),
},
};
});

View file

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

View file

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

View file

@ -1,43 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostAttachmentImage should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Array [
Object {
"alignItems": "flex-start",
"justifyContent": "flex-start",
"marginBottom": 6,
"marginTop": 10,
},
Object {
"height": 100,
},
]
}
type="none"
>
<View>
<Connect(ProgressiveImage)
imageUri="uri"
onError={[MockFunction]}
resizeMode="contain"
style={
Array [
Object {
"alignItems": "center",
"borderRadius": 3,
"justifyContent": "center",
"marginVertical": 1,
},
Object {
"height": 100,
"width": 100,
},
]
}
/>
</View>
</TouchableWithFeedbackIOS>
`;

View file

@ -1,71 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {isGifTooLarge} from '@utils/images';
export default class PostAttachmentImage extends React.PureComponent {
static propTypes = {
height: PropTypes.number.isRequired,
id: PropTypes.string,
imageMetadata: PropTypes.object,
onError: PropTypes.func.isRequired,
onImagePress: PropTypes.func.isRequired,
uri: PropTypes.string,
width: PropTypes.number.isRequired,
};
static defaultProps = {
frameCount: 0,
};
handlePress = () => {
this.props.onImagePress();
};
render() {
if (isGifTooLarge(this.props.imageMetadata)) {
return null;
}
// Note that TouchableWithoutFeedback only works if its child is a View
return (
<TouchableWithFeedback
onPress={this.handlePress}
style={[styles.imageContainer, {height: this.props.height}]}
type={'none'}
>
<View>
<ProgressiveImage
id={this.props.id}
style={[styles.image, {width: this.props.width, height: this.props.height}]}
imageUri={this.props.uri}
resizeMode='contain'
onError={this.props.onError}
/>
</View>
</TouchableWithFeedback>
);
}
}
const styles = StyleSheet.create({
imageContainer: {
alignItems: 'flex-start',
justifyContent: 'flex-start',
marginBottom: 6,
marginTop: 10,
},
image: {
alignItems: 'center',
borderRadius: 3,
justifyContent: 'center',
marginVertical: 1,
},
});

View file

@ -1,23 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import PostAttachmentImage from './index';
describe('PostAttachmentImage', () => {
const baseProps = {
height: 100,
width: 100,
onError: jest.fn(),
onImagePress: jest.fn(),
uri: 'uri',
};
it('should match snapshot', () => {
const wrapper = shallow(<PostAttachmentImage {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -1,374 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostAttachmentOpenGraph should match snapshot, without image and description 1`] = `null`;
exports[`PostAttachmentOpenGraph should match snapshot, without image and description 2`] = `
<View
style={
Object {
"borderColor": "rgba(61,60,64,0.2)",
"borderRadius": 3,
"borderWidth": 1,
"flex": 1,
"marginTop": 10,
"padding": 10,
}
}
>
<View
style={
Object {
"flex": 1,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"color": "rgba(61,60,64,0.5)",
"fontSize": 12,
"marginBottom": 10,
}
}
>
Mattermost
</Text>
</View>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Object {
"flex": 1,
}
}
type="opacity"
>
<Text
ellipsizeMode="tail"
numberOfLines={3}
style={
Array [
Object {
"color": "#2389d7",
"fontSize": 14,
"marginBottom": 10,
},
Object {
"marginRight": 0,
},
]
}
>
Title
</Text>
</TouchableWithFeedbackIOS>
</View>
<View
style={
Array [
Object {
"alignItems": "center",
"borderColor": "rgba(61,60,64,0.2)",
"borderRadius": 3,
"borderWidth": 1,
"marginTop": 5,
},
Object {
"height": 69.83261802575107,
"width": 307,
},
]
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
type="none"
>
<FastImage
nativeID="image-123"
resizeMode="contain"
source={
Object {
"uri": "https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png",
}
}
style={
Array [
Object {
"borderRadius": 3,
},
Object {
"height": 69.83261802575107,
"width": 307,
},
]
}
/>
</TouchableWithFeedbackIOS>
</View>
</View>
`;
exports[`PostAttachmentOpenGraph should match snapshot, without site_name 1`] = `
<View
style={
Object {
"borderColor": "rgba(61,60,64,0.2)",
"borderRadius": 3,
"borderWidth": 1,
"flex": 1,
"marginTop": 10,
"padding": 10,
}
}
>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Object {
"flex": 1,
}
}
type="opacity"
>
<Text
ellipsizeMode="tail"
numberOfLines={3}
style={
Array [
Object {
"color": "#2389d7",
"fontSize": 14,
"marginBottom": 10,
},
Object {
"marginRight": 0,
},
]
}
>
Title
</Text>
</TouchableWithFeedbackIOS>
</View>
</View>
`;
exports[`PostAttachmentOpenGraph should match snapshot, without title and url 1`] = `
<View
style={
Object {
"borderColor": "rgba(61,60,64,0.2)",
"borderRadius": 3,
"borderWidth": 1,
"flex": 1,
"marginTop": 10,
"padding": 10,
}
}
>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Object {
"flex": 1,
}
}
type="opacity"
>
<Text
ellipsizeMode="tail"
numberOfLines={3}
style={
Array [
Object {
"color": "#2389d7",
"fontSize": 14,
"marginBottom": 10,
},
Object {
"marginRight": 0,
},
]
}
>
https://mattermost.com/
</Text>
</TouchableWithFeedbackIOS>
</View>
</View>
`;
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderDescription 1`] = `null`;
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderDescription 2`] = `
<View
style={
Object {
"flex": 1,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={5}
style={
Object {
"color": "rgba(61,60,64,0.7)",
"fontSize": 13,
"marginBottom": 10,
}
}
>
Description
</Text>
</View>
`;
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 1`] = `null`;
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 2`] = `
<View
style={
Array [
Object {
"alignItems": "center",
"borderColor": "rgba(61,60,64,0.2)",
"borderRadius": 3,
"borderWidth": 1,
"marginTop": 5,
},
Object {
"height": 112.56666666666666,
"width": 307,
},
]
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
type="none"
>
<FastImage
nativeID="image-123"
resizeMode="contain"
source={
Object {
"uri": "https://mattermost.com/logo.png",
}
}
style={
Array [
Object {
"borderRadius": 3,
},
Object {
"height": 112.56666666666666,
"width": 307,
},
]
}
/>
</TouchableWithFeedbackIOS>
</View>
`;
exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage 3`] = `
<View
style={
Object {
"borderColor": "rgba(61,60,64,0.2)",
"borderRadius": 3,
"borderWidth": 1,
"flex": 1,
"marginTop": 10,
"padding": 10,
}
}
>
<View
style={
Object {
"flex": 1,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"color": "rgba(61,60,64,0.5)",
"fontSize": 12,
"marginBottom": 10,
}
}
>
Mattermost
</Text>
</View>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Object {
"flex": 1,
}
}
type="opacity"
>
<Text
ellipsizeMode="tail"
numberOfLines={3}
style={
Array [
Object {
"color": "#2389d7",
"fontSize": 14,
"marginBottom": 10,
},
Object {
"marginRight": 0,
},
]
}
>
Title
</Text>
</TouchableWithFeedbackIOS>
</View>
</View>
`;

View file

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

View file

@ -1,362 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Alert, Text, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {intlShape} from 'react-intl';
import parseUrl from 'url-parse';
import {TABLET_WIDTH} from '@components/sidebars/drawer_layout';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {DeviceTypes} from '@constants';
import {generateId} from '@utils/file';
import {openGalleryAtIndex, calculateDimensions} from '@utils/images';
import {getNearestPoint} from '@utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL, isValidUrl} from '@utils/url';
const MAX_IMAGE_HEIGHT = 150;
const VIEWPORT_IMAGE_OFFSET = 93;
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
export default class PostAttachmentOpenGraph extends PureComponent {
static propTypes = {
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
imagesMetadata: PropTypes.object,
isReplyPost: PropTypes.bool,
link: PropTypes.string.isRequired,
openGraphData: PropTypes.object,
postId: PropTypes.string,
theme: PropTypes.object.isRequired,
};
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props) {
super(props);
this.fileId = generateId();
this.state = this.getBestImageUrlAndDimensions(props.openGraphData);
}
componentDidMount() {
this.mounted = true;
if (this.state.openGraphImageUrl) {
this.getImageSize(this.state.openGraphImageUrl);
}
}
componentWillUnmount() {
this.mounted = false;
}
getBestImageUrlAndDimensions = (data) => {
if (!data || !data.images) {
return {
hasImage: false,
};
}
const {imagesMetadata} = this.props;
const bestDimensions = {
width: this.getViewPostWidth(),
height: MAX_IMAGE_HEIGHT,
};
const bestImage = getNearestPoint(bestDimensions, data.images, 'width', 'height');
const imageUrl = bestImage.secure_url || bestImage.url;
let ogImage;
if (imagesMetadata && imagesMetadata[imageUrl]) {
ogImage = imagesMetadata[imageUrl];
}
if (!ogImage) {
ogImage = data.images.find((i) => i.url === imageUrl || i.secure_url === imageUrl);
}
// Fallback when the ogImage does not have dimensions but there is a metaImage defined
const metaImages = imagesMetadata ? Object.values(imagesMetadata) : null;
if ((!ogImage?.width || !ogImage?.height) && metaImages?.length) {
ogImage = metaImages[0];
}
let dimensions = bestDimensions;
if (ogImage?.width && ogImage?.height) {
dimensions = calculateDimensions(ogImage.height, ogImage.width, this.getViewPostWidth());
}
return {
hasImage: Boolean(isValidUrl(imageUrl) && (ogImage.width && ogImage.height)),
...dimensions,
openGraphImageUrl: imageUrl,
};
};
getFilename = (uri) => {
const link = decodeURIComponent(uri);
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
const extension = filename.split('.').pop();
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
}
return `og-${filename.replace(/:/g, '-')}`;
};
getImageSize = (imageUrl) => {
const {imagesMetadata, openGraphData} = this.props;
const {openGraphImageUrl} = this.state;
let ogImage;
if (imagesMetadata && imagesMetadata[openGraphImageUrl]) {
ogImage = imagesMetadata[openGraphImageUrl];
}
if (!ogImage) {
ogImage = openGraphData?.images?.find((i) => i.url === openGraphImageUrl || i.secure_url === openGraphImageUrl);
}
// Fallback when the ogImage does not have dimensions but there is a metaImage defined
const metaImages = imagesMetadata ? Object.values(imagesMetadata) : null;
if ((!ogImage?.width || !ogImage?.height) && metaImages?.length) {
ogImage = metaImages[0];
}
if (ogImage?.width && ogImage?.height) {
this.setImageSize(imageUrl, ogImage.width, ogImage.height);
}
};
setImageSize = (imageUrl, originalWidth, originalHeight) => {
if (this.mounted) {
const dimensions = calculateDimensions(originalHeight, originalWidth, this.getViewPostWidth());
this.setState({
imageUrl,
originalWidth,
originalHeight,
...dimensions,
});
}
};
getViewPostWidth = () => {
const {deviceHeight, deviceWidth, isReplyPost} = this.props;
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
const viewPortWidth = deviceSize - VIEWPORT_IMAGE_OFFSET - (isReplyPost ? VIEWPORT_IMAGE_REPLY_OFFSET : 0);
const tabletOffset = DeviceTypes.IS_TABLET ? TABLET_WIDTH : 0;
return viewPortWidth - tabletOffset;
};
goToLink = () => {
const {intl} = this.context;
const onError = () => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
};
tryOpenURL(this.props.link, onError);
};
handlePreviewImage = () => {
const {
imageUrl: uri,
openGraphImageUrl: link,
originalWidth,
originalHeight,
} = this.state;
const filename = this.getFilename(link);
const extension = filename.split('.').pop();
const files = [{
id: this.fileId,
name: filename,
extension,
has_preview_image: true,
post_id: this.props.postId,
uri,
width: originalWidth,
height: originalHeight,
}];
openGalleryAtIndex(0, files);
};
renderDescription = () => {
const {openGraphData} = this.props;
if (!openGraphData.description) {
return null;
}
const style = getStyleSheet(this.props.theme);
return (
<View style={style.flex}>
<Text
style={style.siteDescription}
numberOfLines={5}
ellipsizeMode='tail'
>
{openGraphData.description}
</Text>
</View>
);
};
renderImage = () => {
if (!this.state.hasImage) {
return null;
}
const {height, openGraphImageUrl, width} = this.state;
let source;
if (openGraphImageUrl) {
source = {
uri: openGraphImageUrl,
};
}
const style = getStyleSheet(this.props.theme);
return (
<View style={[style.imageContainer, {width, height}]}>
<TouchableWithFeedback
onPress={this.handlePreviewImage}
type={'none'}
>
<FastImage
style={[style.image, {width, height}]}
source={source}
resizeMode='contain'
nativeID={`image-${this.fileId}`}
/>
</TouchableWithFeedback>
</View>
);
};
render() {
const {
isReplyPost,
link,
openGraphData,
theme,
} = this.props;
const style = getStyleSheet(theme);
if (!openGraphData) {
return null;
}
let siteName;
if (openGraphData.site_name) {
siteName = (
<View style={style.flex}>
<Text
style={style.siteTitle}
numberOfLines={1}
ellipsizeMode='tail'
>
{openGraphData.site_name}
</Text>
</View>
);
}
const title = openGraphData.title || openGraphData.url || link;
let siteTitle;
if (title) {
siteTitle = (
<View style={style.wrapper}>
<TouchableWithFeedback
style={style.flex}
onPress={this.goToLink}
type={'opacity'}
>
<Text
style={[style.siteSubtitle, {marginRight: isReplyPost ? 10 : 0}]}
numberOfLines={3}
ellipsizeMode='tail'
>
{title}
</Text>
</TouchableWithFeedback>
</View>
);
}
return (
<View style={style.container}>
{siteName}
{siteTitle}
{this.renderDescription()}
{this.renderImage()}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderWidth: 1,
borderRadius: 3,
marginTop: 10,
padding: 10,
},
flex: {
flex: 1,
},
wrapper: {
flex: 1,
flexDirection: 'row',
},
siteTitle: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
marginBottom: 10,
},
siteSubtitle: {
fontSize: 14,
color: theme.linkColor,
marginBottom: 10,
},
siteDescription: {
fontSize: 13,
color: changeOpacity(theme.centerChannelColor, 0.7),
marginBottom: 10,
},
imageContainer: {
alignItems: 'center',
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderWidth: 1,
borderRadius: 3,
marginTop: 5,
},
image: {
borderRadius: 3,
},
};
});

View file

@ -1,154 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import FastImage from 'react-native-fast-image';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import Preferences from '@mm-redux/constants/preferences';
import PostAttachmentOpenGraph from './post_attachment_opengraph';
describe('PostAttachmentOpenGraph', () => {
const openGraphData = {
site_name: 'Mattermost',
title: 'Title',
url: 'https://mattermost.com/',
images: [{
secure_url: 'https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png',
}],
};
const baseProps = {
actions: {
getOpenGraphMetadata: jest.fn(),
},
deviceHeight: 600,
deviceWidth: 400,
imagesMetadata: {
'https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png': {
width: 1165,
height: 265,
},
},
isReplyPost: false,
link: 'https://mattermost.com/',
theme: Preferences.THEMES.default,
};
test('should match snapshot, without image and description', () => {
let wrapper = shallow(
<PostAttachmentOpenGraph {...baseProps}/>,
);
// should return null
expect(wrapper.getElement()).toMatchSnapshot();
wrapper = shallow(
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={openGraphData}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
});
test('should match snapshot, without site_name', () => {
const newOpenGraphData = {
title: 'Title',
url: 'https://mattermost.com/',
};
const wrapper = shallow(
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={newOpenGraphData}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot, without title and url', () => {
const wrapper = shallow(
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={{}}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match state and snapshot, on renderImage', () => {
let wrapper = shallow(
<PostAttachmentOpenGraph {...baseProps}/>,
);
// should return null
expect(wrapper.instance().renderImage()).toMatchSnapshot();
expect(wrapper.state('hasImage')).toEqual(false);
expect(wrapper.find(FastImage).exists()).toEqual(false);
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(false);
const images = [{height: 440, width: 1200, url: 'https://mattermost.com/logo.png'}];
const openGraphDataWithImage = {...openGraphData, images};
wrapper = shallow(
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={openGraphDataWithImage}
/>,
);
expect(wrapper.instance().renderImage()).toMatchSnapshot();
expect(wrapper.state('hasImage')).toEqual(true);
expect(wrapper.find(FastImage).exists()).toEqual(true);
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
});
test('should match state and snapshot, on renderImage', () => {
const images = [{height: 440, width: 1200, url: '%REACT_APP_WEBSITE_BANNER%'}];
const openGraphDataWithImage = {...openGraphData, images};
const wrapper = shallow(
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={openGraphDataWithImage}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.state('hasImage')).toEqual(false);
expect(wrapper.find(FastImage).exists()).toEqual(false);
expect(wrapper.find(TouchableWithFeedback).exists()).toEqual(true);
});
test('should match state and snapshot, on renderDescription', () => {
const wrapper = shallow(
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={openGraphData}
/>,
);
// should return null
expect(wrapper.instance().renderDescription()).toMatchSnapshot();
const openGraphDataWithDescription = {...openGraphData, description: 'Description'};
wrapper.setProps({openGraphData: openGraphDataWithDescription});
expect(wrapper.instance().renderDescription()).toMatchSnapshot();
});
test('should match result on getFilename', () => {
const wrapper = shallow(
<PostAttachmentOpenGraph {...baseProps}/>,
);
const testCases = [
{link: 'https://mattermost.com/image.png', result: 'og-image.png'},
{link: 'https://mattermost.com/image.jpg', result: 'og-image.jpg'},
{link: 'https://mattermost.com/image', result: 'og-image.png'},
];
testCases.forEach((testCase) => { // eslint-disable-line max-nested-callbacks
expect(wrapper.instance().getFilename(testCase.link)).toEqual(testCase.result);
});
});
});

View file

@ -1,135 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={Object {}}
testID="markdown_text"
>
{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={Object {}}
testID="markdown_text"
>
{username} updated the channel header from: {oldHeader} to: {newHeader}
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for Channel Purpose update 1`] = `
<Text
style={Object {}}
>
{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}
</Text>
`;
exports[`renderSystemMessage uses renderer for OLD archived channel without a username 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={Object {}}
testID="markdown_text"
>
{username} archived the channel
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for archived channel 1`] = `
<Connect(Markdown)
baseTextStyle={Object {}}
disableAtChannelMentionHighlight={true}
disableGallery={true}
onPostPress={[MockFunction]}
textStyles={Object {}}
value="{username} archived the channel"
/>
`;
exports[`renderSystemMessage uses renderer for archived channel 2`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={Object {}}
testID="markdown_text"
>
{username} archived the channel
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={Object {}}
testID="markdown_text"
>
{username} unarchived the channel
</Text>
</Text>
</View>
`;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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