diff --git a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap
new file mode 100644
index 000000000..ea562ae80
--- /dev/null
+++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap
@@ -0,0 +1,570 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`channel_info should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js
index bd999dbb9..11b77a2f4 100644
--- a/app/screens/channel_info/channel_info.js
+++ b/app/screens/channel_info/channel_info.js
@@ -28,6 +28,7 @@ export default class ChannelInfo extends PureComponent {
clearPinnedPosts: PropTypes.func.isRequired,
closeDMChannel: PropTypes.func.isRequired,
closeGMChannel: PropTypes.func.isRequired,
+ convertChannelToPrivate: PropTypes.func.isRequired,
deleteChannel: PropTypes.func.isRequired,
getChannelStats: PropTypes.func.isRequired,
getChannel: PropTypes.func.isRequired,
@@ -60,6 +61,7 @@ export default class ChannelInfo extends PureComponent {
isChannelMuted: PropTypes.bool.isRequired,
isCurrent: PropTypes.bool.isRequired,
isFavorite: PropTypes.bool.isRequired,
+ canConvertChannel: PropTypes.bool.isRequired,
canManageUsers: PropTypes.bool.isRequired,
canEditChannel: PropTypes.bool.isRequired,
ignoreChannelMentions: PropTypes.bool.isRequired,
@@ -176,6 +178,59 @@ export default class ChannelInfo extends PureComponent {
this.handleDeleteOrLeave('delete');
};
+ handleConfirmConvertToPrivate = preventDoubleTap(async () => {
+ const {actions, currentChannel} = this.props;
+ const result = await actions.convertChannelToPrivate(currentChannel.id);
+ const displayName = {displayName: currentChannel.display_name.trim()};
+ const {formatMessage} = this.context.intl;
+ if (result.error) {
+ alertErrorWithFallback(
+ this.context.intl,
+ result.error,
+ {
+ id: t('mobile.channel_info.convert_failed'),
+ defaultMessage: 'We were unable to convert {displayName} to a private channel.',
+ },
+ {
+ displayName,
+ },
+ [{
+ text: formatMessage({id: 'mobile.share_extension.error_close', defaultMessage: 'Close'}),
+ }, {
+ text: formatMessage({id: 'mobile.terms_of_service.alert_retry', defaultMessage: 'Try Again'}),
+ onPress: this.handleConfirmConvertToPrivate,
+ }]
+ );
+ } else {
+ Alert.alert(
+ '',
+ formatMessage({id: t('mobile.channel_info.convert_success'), defaultMessage: '{displayName} is now a private channel.'}, displayName),
+ );
+ }
+ })
+
+ handleConvertToPrivate = preventDoubleTap(() => {
+ const {currentChannel} = this.props;
+ const {formatMessage} = this.context.intl;
+ const displayName = {displayName: currentChannel.display_name.trim()};
+ const title = {id: t('mobile.channel_info.alertTitleConvertChannel'), defaultMessage: 'Convert {displayName} to a private channel?'};
+ const message = {
+ id: t('mobile.channel_info.alertMessageConvertChannel'),
+ defaultMessage: 'When you convert {displayName} to a private channel, history and membership are preserved. Publicly shared files remain accessible to anyone with the link. Membership in a private channel is by invitation only.\n\nThe change is permanent and cannot be undone.\n\nAre you sure you want to convert {displayName} to a private channel?',
+ };
+
+ Alert.alert(
+ formatMessage(title, displayName),
+ formatMessage(message, displayName),
+ [{
+ text: formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}),
+ }, {
+ text: formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}),
+ onPress: this.handleConfirmConvertToPrivate,
+ }],
+ );
+ });
+
handleDeleteOrLeave = preventDoubleTap((eventType) => {
const {formatMessage} = this.context.intl;
const channel = this.props.currentChannel;
@@ -352,6 +407,13 @@ export default class ChannelInfo extends PureComponent {
return isDirectMessage || isGroupMessage;
};
+ renderConvertToPrivateRow = () => {
+ const {currentChannel, canConvertChannel} = this.props;
+ const isDefaultChannel = currentChannel.name === General.DEFAULT_CHANNEL;
+ const isPublicChannel = currentChannel.type === General.OPEN_CHANNEL;
+ return !isDefaultChannel && isPublicChannel && canConvertChannel;
+ }
+
actionsRows = (style, channelIsArchived) => {
const {
currentChannelMemberCount,
@@ -461,6 +523,19 @@ export default class ChannelInfo extends PureComponent {
/>
}
+ {this.renderConvertToPrivateRow() && (
+
+
+
+
+ )}
{canEditChannel && (
diff --git a/app/screens/channel_info/channel_info.test.js b/app/screens/channel_info/channel_info.test.js
new file mode 100644
index 000000000..71d0ce1be
--- /dev/null
+++ b/app/screens/channel_info/channel_info.test.js
@@ -0,0 +1,156 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+import React from 'react';
+import {shallow} from 'enzyme';
+
+import Preferences from 'mattermost-redux/constants/preferences';
+import {General} from 'mattermost-redux/constants';
+
+import ChannelInfo from './channel_info';
+
+// ChannelInfoRow expects to receive the pinIcon as a number
+jest.mock('assets/images/channel_info/pin.png', () => {
+ return 1;
+});
+
+jest.mock('app/utils/theme', () => {
+ const original = require.requireActual('app/utils/theme');
+ return {
+ ...original,
+ changeOpacity: jest.fn(),
+ };
+});
+
+describe('channel_info', () => {
+ const intlMock = {
+ formatMessage: jest.fn(),
+ formatDate: jest.fn(),
+ formatTime: jest.fn(),
+ formatRelative: jest.fn(),
+ formatNumber: jest.fn(),
+ formatPlural: jest.fn(),
+ formatHTMLMessage: jest.fn(),
+ now: jest.fn(),
+ };
+ const baseProps = {
+ canDeleteChannel: true,
+ canConvertChannel: true,
+ canManageUsers: true,
+ viewArchivedChannels: true,
+ canEditChannel: true,
+ currentChannel: {
+ id: '1234',
+ display_name: 'Channel Name',
+ type: General.OPEN_CHANNEL,
+ create_at: 123,
+ delete_at: 0,
+ header: '',
+ purpose: 'Purpose',
+ group_constrained: false,
+ },
+ currentChannelCreatorName: 'Creator',
+ currentChannelMemberCount: 2,
+ currentChannelGuestCount: 0,
+ currentUserId: '1234',
+ currentUserIsGuest: false,
+ isChannelMuted: false,
+ ignoreChannelMentions: false,
+ isCurrent: true,
+ isFavorite: false,
+ status: 'status',
+ theme: Preferences.THEMES.default,
+ isBot: false,
+ isLandscape: false,
+ actions: {
+ clearPinnedPosts: jest.fn(),
+ closeDMChannel: jest.fn(),
+ closeGMChannel: jest.fn(),
+ convertChannelToPrivate: jest.fn(),
+ deleteChannel: jest.fn(),
+ getChannelStats: jest.fn(),
+ getChannel: jest.fn(),
+ leaveChannel: jest.fn(),
+ loadChannelsByTeamName: jest.fn(),
+ favoriteChannel: jest.fn(),
+ unfavoriteChannel: jest.fn(),
+ getCustomEmojisInText: jest.fn(),
+ selectFocusedPostId: jest.fn(),
+ updateChannelNotifyProps: jest.fn(),
+ selectPenultimateChannel: jest.fn(),
+ setChannelDisplayName: jest.fn(),
+ handleSelectChannel: jest.fn(),
+ popTopScreen: jest.fn(),
+ goToScreen: jest.fn(),
+ popToRoot: jest.fn(),
+ dismissModal: jest.fn(),
+ showModalOverCurrentContext: jest.fn(),
+ },
+ };
+
+ test('should match snapshot', async () => {
+ const wrapper = shallow(
+ ,
+ {context: {intl: intlMock}},
+ );
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
+ test('should render convert to private button when user has team admin permissions', async () => {
+ const wrapper = shallow(
+ ,
+ {context: {intl: intlMock}},
+ );
+
+ const instance = wrapper.instance();
+ const render = instance.renderConvertToPrivateRow();
+ expect(render).toBeTruthy();
+ });
+
+ test('should not render convert to private button when user is not team admin or above', async () => {
+ const wrapper = shallow(
+ ,
+ {context: {intl: intlMock}},
+ );
+
+ const instance = wrapper.instance();
+ const render = instance.renderConvertToPrivateRow();
+ expect(render).toBeFalsy();
+ });
+
+ test('should not render convert to private button currentChannel is already private', async () => {
+ const props = Object.assign({}, baseProps);
+ props.currentChannel.type = General.PRIVATE_CHANNEL;
+ const wrapper = shallow(
+ ,
+ {context: {intl: intlMock}},
+ );
+
+ const instance = wrapper.instance();
+ const render = instance.renderConvertToPrivateRow();
+ expect(render).toBeFalsy();
+ });
+
+ test('should not render convert to private button when currentChannel is a default channel', async () => {
+ const props = Object.assign({}, baseProps);
+ props.currentChannel.name = General.DEFAULT_CHANNEL;
+ const wrapper = shallow(
+ ,
+ {context: {intl: intlMock}},
+ );
+
+ const instance = wrapper.instance();
+ const render = instance.renderConvertToPrivateRow();
+ expect(render).toBeFalsy();
+ });
+});
diff --git a/app/screens/channel_info/index.js b/app/screens/channel_info/index.js
index 74a558da9..bbf55994a 100644
--- a/app/screens/channel_info/index.js
+++ b/app/screens/channel_info/index.js
@@ -5,6 +5,7 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {
+ convertChannelToPrivate,
favoriteChannel,
getChannelStats,
getChannel,
@@ -105,6 +106,7 @@ function mapStateToProps(state) {
return {
canDeleteChannel: showDeleteOption(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin),
+ canConvertChannel: isAdmin,
viewArchivedChannels,
canEditChannel,
currentChannel,
@@ -132,6 +134,7 @@ function mapDispatchToProps(dispatch) {
clearPinnedPosts,
closeDMChannel,
closeGMChannel,
+ convertChannelToPrivate,
deleteChannel,
getChannelStats,
getChannel,
diff --git a/app/utils/general.js b/app/utils/general.js
index d8b962dfe..4352bec08 100644
--- a/app/utils/general.js
+++ b/app/utils/general.js
@@ -17,12 +17,12 @@ export function toTitleCase(str) {
return str.replace(/\w\S*/g, doTitleCase);
}
-export function alertErrorWithFallback(intl, error, fallback, values) {
+export function alertErrorWithFallback(intl, error, fallback, values, buttons) {
let msg = error.message;
if (!msg || msg === 'Network request failed') {
msg = intl.formatMessage(fallback, values);
}
- Alert.alert('', msg);
+ Alert.alert('', msg, buttons);
}
export function alertErrorIfInvalidPermissions(result) {
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 4cef5347d..10715dce2 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -156,12 +156,17 @@
"mobile.camera_video_permission_denied_description": "Take videos and upload them to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your camera.",
"mobile.camera_video_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.channel_drawer.search": "Jump to...",
+ "mobile.channel_info.alertMessageConvertChannel": "When you convert {displayName} to a private channel, history and membership are preserved. Publicly shared files remain accessible to anyone with the link. Membership in a private channel is by invitation only.\n\nThe change is permanent and cannot be undone.\n\nAre you sure you want to convert {displayName} to a private channel?",
"mobile.channel_info.alertMessageDeleteChannel": "Are you sure you want to archive the {term} {name}?",
"mobile.channel_info.alertMessageLeaveChannel": "Are you sure you want to leave the {term} {name}?",
"mobile.channel_info.alertNo": "No",
+ "mobile.channel_info.alertTitleConvertChannel": "Convert {displayName} to a private channel?",
"mobile.channel_info.alertTitleDeleteChannel": "Archive {term}",
"mobile.channel_info.alertTitleLeaveChannel": "Leave {term}",
"mobile.channel_info.alertYes": "Yes",
+ "mobile.channel_info.convert": "Convert to Private Channel",
+ "mobile.channel_info.convert_failed": "We were unable to convert {displayName} to a private channel.",
+ "mobile.channel_info.convert_success": "{displayName} is now a private channel.",
"mobile.channel_info.copy_header": "Copy Header",
"mobile.channel_info.copy_purpose": "Copy Purpose",
"mobile.channel_info.delete_failed": "We couldn't archive the channel {displayName}. Please check your connection and try again.",