diff --git a/app/components/post_body/__snapshots__/system_message_helpers.test.js.snap b/app/components/post_body/__snapshots__/system_message_helpers.test.js.snap index e864ae21a..6329d2e31 100644 --- a/app/components/post_body/__snapshots__/system_message_helpers.test.js.snap +++ b/app/components/post_body/__snapshots__/system_message_helpers.test.js.snap @@ -35,3 +35,12 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = ` value="{username} archived the channel" /> `; + +exports[`renderSystemMessage uses renderer for unarchived channel 1`] = ` + +`; diff --git a/app/components/post_body/system_message_helpers.js b/app/components/post_body/system_message_helpers.js index 2a9c2f569..1a3c537a3 100644 --- a/app/components/post_body/system_message_helpers.js +++ b/app/components/post_body/system_message_helpers.js @@ -144,11 +144,25 @@ const renderArchivedMessage = (postBodyProps, styles, intl) => { return renderMessage(postBodyProps, styles, intl, localeHolder, values); }; +const renderUnarchivedMessage = (postBodyProps, styles, intl) => { + const {postProps} = postBodyProps; + + const username = renderUsername(postProps.username); + const localeHolder = { + id: t('mobile.system_message.channel_unarchived_message'), + defaultMessage: '{username} unarchived the channel', + }; + + const values = {username}; + return renderMessage(postBodyProps, styles, intl, localeHolder, values); +}; + const systemMessageRenderers = { [Posts.POST_TYPES.HEADER_CHANGE]: renderHeaderChangeMessage, [Posts.POST_TYPES.DISPLAYNAME_CHANGE]: renderDisplayNameChangeMessage, [Posts.POST_TYPES.PURPOSE_CHANGE]: renderPurposeChangeMessage, [Posts.POST_TYPES.CHANNEL_DELETED]: renderArchivedMessage, + [Posts.POST_TYPES.CHANNEL_UNARCHIVED]: renderUnarchivedMessage, }; export const renderSystemMessage = (postBodyProps, styles, intl) => { diff --git a/app/components/post_body/system_message_helpers.test.js b/app/components/post_body/system_message_helpers.test.js index e533edda0..d4f11734c 100644 --- a/app/components/post_body/system_message_helpers.test.js +++ b/app/components/post_body/system_message_helpers.test.js @@ -78,6 +78,19 @@ describe('renderSystemMessage', () => { expect(renderedMessage).toMatchSnapshot(); }); + test('uses renderer for unarchived channel', () => { + const postBodyProps = { + ...basePostBodyProps, + postProps: { + ...basePostBodyProps.postProps, + }, + postType: Posts.POST_TYPES.CHANNEL_UNARCHIVED, + }; + + const renderedMessage = SystemMessageHelpers.renderSystemMessage(postBodyProps, mockStyles, mockIntl); + expect(renderedMessage).toMatchSnapshot(); + }); + test('is null for non-qualifying system messages', () => { const postBodyProps = { ...basePostBodyProps, diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js index 47b081137..a7da28140 100644 --- a/app/screens/channel_info/channel_info.js +++ b/app/screens/channel_info/channel_info.js @@ -33,6 +33,7 @@ export default class ChannelInfo extends PureComponent { closeGMChannel: PropTypes.func.isRequired, convertChannelToPrivate: PropTypes.func.isRequired, deleteChannel: PropTypes.func.isRequired, + unarchiveChannel: PropTypes.func.isRequired, getChannelStats: PropTypes.func.isRequired, getChannel: PropTypes.func.isRequired, leaveChannel: PropTypes.func.isRequired, @@ -49,6 +50,7 @@ export default class ChannelInfo extends PureComponent { componentId: PropTypes.string, viewArchivedChannels: PropTypes.bool.isRequired, canDeleteChannel: PropTypes.bool.isRequired, + canUnarchiveChannel: PropTypes.bool.isRequired, currentChannel: PropTypes.object.isRequired, currentChannelCreatorName: PropTypes.string, currentChannelMemberCount: PropTypes.number, @@ -173,14 +175,6 @@ export default class ChannelInfo extends PureComponent { goToScreen(screen, title); }); - handleLeave = () => { - this.handleDeleteOrLeave('leave'); - }; - - handleDelete = () => { - this.handleDeleteOrLeave('delete'); - }; - handleConfirmConvertToPrivate = preventDoubleTap(async () => { const {actions, currentChannel} = this.props; const result = await actions.convertChannelToPrivate(currentChannel.id); @@ -234,58 +228,92 @@ export default class ChannelInfo extends PureComponent { ); }); - handleDeleteOrLeave = preventDoubleTap((eventType) => { + handleLeave = preventDoubleTap(() => { + const title = {id: t('mobile.channel_info.alertTitleLeaveChannel'), defaultMessage: 'Leave {term}'}; + const message = { + id: t('mobile.channel_info.alertMessageLeaveChannel'), + defaultMessage: 'Are you sure you want to leave the {term} {name}?', + }; + const onPressAction = () => { + this.props.actions.leaveChannel(this.props.currentChannel, true).then(() => { + this.close(); + }); + }; + this.alertAndHandleYesAction(title, message, onPressAction); + }); + + handleDelete = preventDoubleTap(() => { + const channel = this.props.currentChannel; + const title = {id: t('mobile.channel_info.alertTitleDeleteChannel'), defaultMessage: 'Archive {term}'}; + const message = { + id: t('mobile.channel_info.alertMessageDeleteChannel'), + defaultMessage: 'Are you sure you want to archive the {term} {name}?', + }; + const onPressAction = async () => { + const result = await this.props.actions.deleteChannel(channel.id); + if (result.error) { + alertErrorWithFallback( + this.context.intl, + result.error, + { + id: t('mobile.channel_info.delete_failed'), + defaultMessage: "We couldn't archive the channel {displayName}. Please check your connection and try again.", + }, + { + displayName: channel.display_name.trim(), + }, + ); + if (result.error.server_error_id === 'api.channel.delete_channel.deleted.app_error') { + this.props.actions.getChannel(channel.id); + } + } else if (this.props.viewArchivedChannels) { + this.props.actions.handleSelectChannel(channel.id); + this.close(false); + } else { + this.props.actions.selectPenultimateChannel(channel.team_id); + this.close(false); + } + }; + this.alertAndHandleYesAction(title, message, onPressAction); + }); + + handleUnarchive = preventDoubleTap(() => { + const channel = this.props.currentChannel; + const title = {id: t('mobile.channel_info.alertTitleUnarchiveChannel'), defaultMessage: 'Unarchive {term}'}; + const message = { + id: t('mobile.channel_info.alertMessageUnarchiveChannel'), + defaultMessage: 'Are you sure you want to unarchive the {term} {name}?', + }; + const onPressAction = async () => { + const result = await this.props.actions.unarchiveChannel(channel.id); + if (result.error) { + alertErrorWithFallback( + this.context.intl, + result.error, + { + id: t('mobile.channel_info.unarchive_failed'), + defaultMessage: "We couldn't unarchive the channel {displayName}. Please check your connection and try again.", + }, + { + displayName: channel.display_name.trim(), + }, + ); + if (result.error.server_error_id === 'api.channel.unarchive_channel.unarchive.app_error') { + this.props.actions.getChannel(channel.id); + } + } else { + this.close(false); + } + }; + this.alertAndHandleYesAction(title, message, onPressAction); + }); + + alertAndHandleYesAction = (title, message, onPressAction) => { const {formatMessage} = this.context.intl; const channel = this.props.currentChannel; const term = channel.type === General.OPEN_CHANNEL ? formatMessage({id: 'mobile.channel_info.publicChannel', defaultMessage: 'Public Channel'}) : formatMessage({id: 'mobile.channel_info.privateChannel', defaultMessage: 'Private Channel'}); - let title; - let message; - let onPressAction; - if (eventType === 'leave') { - title = {id: t('mobile.channel_info.alertTitleLeaveChannel'), defaultMessage: 'Leave {term}'}; - message = { - id: t('mobile.channel_info.alertMessageLeaveChannel'), - defaultMessage: 'Are you sure you want to leave the {term} {name}?', - }; - onPressAction = () => { - this.props.actions.leaveChannel(channel, true).then(() => { - this.close(); - }); - }; - } else if (eventType === 'delete') { - title = {id: t('mobile.channel_info.alertTitleDeleteChannel'), defaultMessage: 'Archive {term}'}; - message = { - id: t('mobile.channel_info.alertMessageDeleteChannel'), - defaultMessage: 'Are you sure you want to archive the {term} {name}?', - }; - onPressAction = async () => { - const result = await this.props.actions.deleteChannel(channel.id); - if (result.error) { - alertErrorWithFallback( - this.context.intl, - result.error, - { - id: t('mobile.channel_info.delete_failed'), - defaultMessage: "We couldn't archive the channel {displayName}. Please check your connection and try again.", - }, - { - displayName: channel.display_name.trim(), - }, - ); - if (result.error.server_error_id === 'api.channel.delete_channel.deleted.app_error') { - this.props.actions.getChannel(channel.id); - } - } else if (this.props.viewArchivedChannels) { - this.props.actions.handleSelectChannel(channel.id); - this.close(false); - } else { - this.props.actions.selectPenultimateChannel(channel.team_id); - this.close(false); - } - }; - } Alert.alert( formatMessage(title, {term}), @@ -303,7 +331,7 @@ export default class ChannelInfo extends PureComponent { onPress: onPressAction, }], ); - }); + } handleClose = preventDoubleTap(() => { const {currentChannel, isCurrent, isFavorite} = this.props; @@ -410,6 +438,19 @@ export default class ChannelInfo extends PureComponent { return isDirectMessage || isGroupMessage; }; + renderUnarchiveChannel = () => { + const {canUnarchiveChannel} = this.props; + if (!canUnarchiveChannel) { + return false; + } + const channel = this.props.currentChannel; + const channelIsArchived = channel.delete_at !== 0; + const isDirectMessage = channel.type === General.DM_CHANNEL; + const isGroupMessage = channel.type === General.GM_CHANNEL; + + return channelIsArchived && (!isDirectMessage && !isGroupMessage); + }; + renderConvertToPrivateRow = () => { const {currentChannel, canConvertChannel} = this.props; const isDefaultChannel = currentChannel.name === General.DEFAULT_CHANNEL; @@ -440,7 +481,6 @@ export default class ChannelInfo extends PureComponent { theme={theme} isLandscape={isLandscape} /> - ); } @@ -627,6 +667,19 @@ export default class ChannelInfo extends PureComponent { theme={theme} isLandscape={isLandscape} /> + {this.renderUnarchiveChannel() && + + + + + } } diff --git a/app/screens/channel_info/channel_info.test.js b/app/screens/channel_info/channel_info.test.js index 42dc7d8b5..bad5f6a3c 100644 --- a/app/screens/channel_info/channel_info.test.js +++ b/app/screens/channel_info/channel_info.test.js @@ -36,6 +36,7 @@ describe('channel_info', () => { }; const baseProps = { canDeleteChannel: true, + canUnarchiveChannel: false, canConvertChannel: true, canManageUsers: true, viewArchivedChannels: true, @@ -70,6 +71,7 @@ describe('channel_info', () => { closeGMChannel: jest.fn(), convertChannelToPrivate: jest.fn(), deleteChannel: jest.fn(), + unarchiveChannel: jest.fn(), getChannelStats: jest.fn(), getChannel: jest.fn(), leaveChannel: jest.fn(), @@ -166,4 +168,38 @@ describe('channel_info', () => { instance.close(); expect(dismissModal).toHaveBeenCalled(); }); + + test('should render unarchive channel button when currentChannel is an archived channel', async () => { + const props = Object.assign({}, baseProps); + props.canUnarchiveChannel = true; + props.currentChannel.delete_at = 1234566; + + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + + const instance = wrapper.instance(); + const render = instance.renderUnarchiveChannel(); + expect(render).toBeTruthy(); + }); + + test('should not render unarchive channel button when currentChannel is an active channel', async () => { + const props = Object.assign({}, baseProps); + props.canUnarchiveChannel = false; + props.currentChannel.delete_at = 0; + + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + + const instance = wrapper.instance(); + const render = instance.renderUnarchiveChannel(); + expect(render).toBeFalsy(); + }); }); diff --git a/app/screens/channel_info/index.js b/app/screens/channel_info/index.js index ed82fb30e..a699f0547 100644 --- a/app/screens/channel_info/index.js +++ b/app/screens/channel_info/index.js @@ -10,6 +10,7 @@ import { getChannelStats, getChannel, deleteChannel, + unarchiveChannel, unfavoriteChannel, updateChannelNotifyProps, } from 'mattermost-redux/actions/channels'; @@ -29,9 +30,13 @@ import { import {getCurrentUserId, getUser, getStatusForUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users'; import {areChannelMentionsIgnored, getUserIdFromChannelName, isChannelMuted, showDeleteOption, showManagementOptions} from 'mattermost-redux/utils/channel_utils'; import {isAdmin as checkIsAdmin, isChannelAdmin as checkIsChannelAdmin, isSystemAdmin as checkIsSystemAdmin} from 'mattermost-redux/utils/user_utils'; -import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getConfig, getLicense, hasNewPermissions} from 'mattermost-redux/selectors/entities/general'; import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone'; import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; +import Permissions from 'mattermost-redux/constants/permissions'; +import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers'; import { closeDMChannel, @@ -63,6 +68,7 @@ function mapStateToProps(state) { const isCurrent = currentChannel.id === state.entities.channels.currentChannelId; const isFavorite = favoriteChannels && favoriteChannels.indexOf(currentChannel.id) > -1; const roles = getCurrentUserRoles(state); + const {serverVersion} = state.entities.general; let canManageUsers = currentChannel.hasOwnProperty('id') ? canManageChannelMembers(state) : false; if (currentChannel.group_constrained) { canManageUsers = false; @@ -100,8 +106,17 @@ function mapStateToProps(state) { timeZone = getUserCurrentTimezone(currentUser.timezone); } + let canUnarchiveChannel = false; + if (hasNewPermissions(state) && isMinimumServerVersion(serverVersion, 5, 20)) { + canUnarchiveChannel = haveITeamPermission(state, { + team: getCurrentTeamId(state), + permission: Permissions.MANAGE_TEAM, + }); + } + return { canDeleteChannel: showDeleteOption(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin), + canUnarchiveChannel, canConvertChannel: isAdmin, viewArchivedChannels, canEditChannel, @@ -134,6 +149,7 @@ function mapDispatchToProps(dispatch) { closeGMChannel, convertChannelToPrivate, deleteChannel, + unarchiveChannel, getChannelStats, getChannel, leaveChannel, diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 111d5217f..491df5c26 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -159,10 +159,12 @@ "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.alertMessageUnarchiveChannel": "Are you sure you want to unarchive 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.alertTitleUnarchiveChannel": "Unarchive {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.", @@ -173,6 +175,7 @@ "mobile.channel_info.edit": "Edit Channel", "mobile.channel_info.privateChannel": "Private Channel", "mobile.channel_info.publicChannel": "Public Channel", + "mobile.channel_info.unarchive_failed": "We couldn't unarchive the channel {displayName}. Please check your connection and try again.", "mobile.channel_list.alertNo": "No", "mobile.channel_list.alertYes": "Yes", "mobile.channel_list.archived": "ARCHIVED", @@ -411,6 +414,7 @@ "mobile.routes.channelInfo.delete_channel": "Archive Channel", "mobile.routes.channelInfo.favorite": "Favorite", "mobile.routes.channelInfo.groupManaged": "Members are managed by linked groups", + "mobile.routes.channelInfo.unarchive_channel": "Unarchive Channel", "mobile.routes.code": "{language} Code", "mobile.routes.code.noLanguage": "Code", "mobile.routes.edit_profile": "Edit Profile", @@ -469,6 +473,7 @@ "mobile.storage_permission_denied_title": "{applicationName} would like to access your files", "mobile.suggestion.members": "Members", "mobile.system_message.channel_archived_message": "{username} archived the channel", + "mobile.system_message.channel_unarchived_message": "{username} unarchived the channel", "mobile.system_message.update_channel_displayname_message_and_forget.updated_from": "{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}", "mobile.system_message.update_channel_header_message_and_forget.removed": "{username} removed the channel header (was: {oldHeader})", "mobile.system_message.update_channel_header_message_and_forget.updated_from": "{username} updated the channel header from: {oldHeader} to: {newHeader}",