From b54d1342990253d3fe4a00c64df27cdd1bb0719b Mon Sep 17 00:00:00 2001 From: David Meza Date: Thu, 3 Aug 2017 07:59:42 -0500 Subject: PLT-6484 Add /leave command to leave a channel (#6402) * PLT-6484 Add /leave command to leave a channel * Text changes requeted on review. * PLT-6484 Display the right error message when trying to /leave town-square * PLT-6484 Be able to execute /leave command in direct and group message channels with the same effect as clicking x * PLT-6484 Refactor to create new leave_private_channel_modal.jsx * PLT-6484 Remove previous leave private channel logic to use new leave_private_channel_modal.jsx * Remove dot in command description. Change localized error when leaving Town square. * disable /leave command in reply threads on the right-hand sidebar, since it is not obvious which channel you should leave --- api/command_leave_test.go | 66 ++++++++++++ app/command_leave.go | 55 ++++++++++ i18n/en.json | 24 +++++ webapp/actions/channel_actions.jsx | 33 ++++++ webapp/actions/global_actions.jsx | 11 +- webapp/components/channel_header.jsx | 59 +--------- .../modals/leave_private_channel_modal.jsx | 120 +++++++++++++++++++++ webapp/components/navbar.jsx | 62 +---------- webapp/components/needs_team/needs_team.jsx | 2 + webapp/stores/modal_store.jsx | 1 + webapp/utils/constants.jsx | 1 + 11 files changed, 315 insertions(+), 119 deletions(-) create mode 100644 api/command_leave_test.go create mode 100644 app/command_leave.go create mode 100644 webapp/components/modals/leave_private_channel_modal.jsx diff --git a/api/command_leave_test.go b/api/command_leave_test.go new file mode 100644 index 000000000..ae098b8a2 --- /dev/null +++ b/api/command_leave_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "strings" + "testing" + + "github.com/mattermost/platform/model" +) + +func TestLeaveCommands(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + team := th.BasicTeam + user2 := th.BasicUser2 + + channel1 := &model.Channel{DisplayName: "AA", Name: "aa" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + Client.Must(Client.JoinChannel(channel1.Id)) + + channel2 := &model.Channel{DisplayName: "BB", Name: "bb" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id} + channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) + Client.Must(Client.JoinChannel(channel2.Id)) + Client.Must(Client.AddChannelMember(channel2.Id, user2.Id)) + + channel3 := Client.Must(Client.CreateDirectChannel(user2.Id)).Data.(*model.Channel) + + rs1 := Client.Must(Client.Command(channel1.Id, "/leave")).Data.(*model.CommandResponse) + if !strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+model.DEFAULT_CHANNEL) { + t.Fatal("failed to leave open channel 1") + } + + rs2 := Client.Must(Client.Command(channel2.Id, "/leave")).Data.(*model.CommandResponse) + if !strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+model.DEFAULT_CHANNEL) { + t.Fatal("failed to leave private channel 1") + } + + rs3 := Client.Must(Client.Command(channel3.Id, "/leave")).Data.(*model.CommandResponse) + if strings.HasSuffix(rs3.GotoLocation, "/"+team.Name+"/channels/"+model.DEFAULT_CHANNEL) { + t.Fatal("should not have left direct message channel") + } + + cdata := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList) + + found := false + for _, c := range *cdata { + if c.Id == channel1.Id || c.Id == channel2.Id { + found = true + } + } + + if found { + t.Fatal("did not leave right channels") + } + + for _, c := range *cdata { + if c.Name == model.DEFAULT_CHANNEL { + if _, err := Client.LeaveChannel(c.Id); err == nil { + t.Fatal("should have errored on leaving default channel") + } + break + } + } +} diff --git a/app/command_leave.go b/app/command_leave.go new file mode 100644 index 000000000..963172e86 --- /dev/null +++ b/app/command_leave.go @@ -0,0 +1,55 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/platform/model" + goi18n "github.com/nicksnyder/go-i18n/i18n" +) + +type LeaveProvider struct { +} + +const ( + CMD_LEAVE = "leave" +) + +func init() { + RegisterCommandProvider(&LeaveProvider{}) +} + +func (me *LeaveProvider) GetTrigger() string { + return CMD_LEAVE +} + +func (me *LeaveProvider) GetCommand(T goi18n.TranslateFunc) *model.Command { + return &model.Command{ + Trigger: CMD_LEAVE, + AutoComplete: true, + AutoCompleteDesc: T("api.command_leave.desc"), + DisplayName: T("api.command_leave.name"), + } +} + +func (me *LeaveProvider) DoCommand(args *model.CommandArgs, message string) *model.CommandResponse { + var channel *model.Channel + var noChannelErr *model.AppError + if channel, noChannelErr = GetChannel(args.ChannelId); noChannelErr != nil { + return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} + } + if channel.Name == model.DEFAULT_CHANNEL { + return &model.CommandResponse{Text: args.T("api.channel.leave.default.app_error", map[string]interface{}{"Channel": model.DEFAULT_CHANNEL}), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} + } + err := LeaveChannel(args.ChannelId, args.UserId) + if err != nil { + return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} + } + + team, err := GetTeam(args.TeamId) + if err != nil { + return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} + } + + return &model.CommandResponse{GotoLocation: args.SiteURL + "/" + team.Name + "/channels/" + model.DEFAULT_CHANNEL, Text: args.T("api.command_leave.success"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} +} diff --git a/i18n/en.json b/i18n/en.json index c4bc9652a..abbf73699 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -691,6 +691,30 @@ "id": "api.command_join.success", "translation": "Joined channel." }, + { + "id": "api.command_leave.desc", + "translation": "Leave the current channel" + }, + { + "id": "api.command_leave.fail.app_error", + "translation": "An error occurred while leaving the channel." + }, + { + "id": "api.command_leave.list.app_error", + "translation": "An error occurred while listing channels." + }, + { + "id": "api.command_leave.missing.app_error", + "translation": "We couldn't find the channel." + }, + { + "id": "api.command_leave.name", + "translation": "leave" + }, + { + "id": "api.command_leave.success", + "translation": "Left the channel." + }, { "id": "api.command_logout.desc", "translation": "Logout of Mattermost" diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx index 39dc37591..78df1ff17 100644 --- a/webapp/actions/channel_actions.jsx +++ b/webapp/actions/channel_actions.jsx @@ -72,6 +72,39 @@ export function executeCommand(message, args, success, error) { msg = '/shortcuts'; } break; + case '/leave': { + // /leave command not supported in reply threads. + if (args.channel_id && (args.root_id || args.parent_id)) { + GlobalActions.sendEphemeralPost('/leave is not supported in reply threads. Use it in the center channel instead.', args.channel_id, args.parent_id); + return; + } + const channel = ChannelStore.getCurrent(); + if (channel.type === Constants.PRIVATE_CHANNEL) { + GlobalActions.showLeavePrivateChannelModal(channel); + return; + } else if ( + channel.type === Constants.DM_CHANNEL || + channel.type === Constants.GM_CHANNEL + ) { + let name; + let category; + if (channel.type === Constants.DM_CHANNEL) { + name = Utils.getUserIdFromChannelName(channel); + category = Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW; + } else { + name = channel.id; + category = Constants.Preferences.CATEGORY_GROUP_CHANNEL_SHOW; + } + const currentUserId = UserStore.getCurrentId(); + savePreferences(currentUserId, [{category, name, user_id: currentUserId, value: 'false'}])(dispatch, getState); + if (ChannelUtils.isFavoriteChannel(channel)) { + unmarkFavorite(channel.id); + } + browserHistory.push(`${TeamStore.getCurrentTeamRelativeUrl()}/channels/town-square`); + return; + } + break; + } case '/settings': GlobalActions.showAccountSettingsModal(); return; diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index f464483cf..a163db126 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -281,6 +281,13 @@ export function showLeaveTeamModal() { }); } +export function showLeavePrivateChannelModal(channel) { + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_LEAVE_PRIVATE_CHANNEL_MODAL, + value: channel + }); +} + export function emitSuggestionPretextChanged(suggestionId, pretext) { AppDispatcher.handleViewAction({ type: ActionTypes.SUGGESTION_PRETEXT_CHANGED, @@ -351,7 +358,7 @@ export function emitPreferencesDeletedEvent(preferences) { }); } -export function sendEphemeralPost(message, channelId) { +export function sendEphemeralPost(message, channelId, parentId) { const timestamp = Utils.getTimestamp(); const post = { id: Utils.generateId(), @@ -361,6 +368,8 @@ export function sendEphemeralPost(message, channelId) { type: Constants.PostTypes.EPHEMERAL, create_at: timestamp, update_at: timestamp, + root_id: parentId, + parent_id: parentId, props: {} }; diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index 10e568794..bee06c700 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -14,7 +14,6 @@ import ChannelMembersModal from './channel_members_modal.jsx'; import ChannelNotificationsModal from './channel_notifications_modal.jsx'; import DeleteChannelModal from './delete_channel_modal.jsx'; import RenameChannelModal from './rename_channel_modal.jsx'; -import ConfirmModal from './confirm_modal.jsx'; import ToggleModalButton from './toggle_modal_button.jsx'; import ChannelStore from 'stores/channel_store.jsx'; @@ -60,8 +59,6 @@ export default class ChannelHeader extends React.Component { this.initWebrtc = this.initWebrtc.bind(this); this.onBusy = this.onBusy.bind(this); this.openDirectMessageModal = this.openDirectMessageModal.bind(this); - this.createLeaveChannelModal = this.createLeaveChannelModal.bind(this); - this.hideLeaveChannelModal = this.hideLeaveChannelModal.bind(this); const state = this.getStateFromStores(); state.showEditChannelHeaderModal = false; @@ -91,7 +88,6 @@ export default class ChannelHeader extends React.Component { enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true), isBusy: WebrtcStore.isBusy(), isFavorite: channel && ChannelUtils.isFavoriteChannel(channel), - showLeaveChannelModal: false, pinsOpen: SearchStore.getIsPinnedPosts() }; } @@ -144,9 +140,7 @@ export default class ChannelHeader extends React.Component { handleLeave() { if (this.state.channel.type === Constants.PRIVATE_CHANNEL) { - this.setState({ - showLeaveChannelModal: true - }); + GlobalActions.showLeavePrivateChannelModal(this.state.channel); } else { ChannelActions.leaveChannel(this.state.channel.id); } @@ -233,54 +227,6 @@ export default class ChannelHeader extends React.Component { }); } - hideLeaveChannelModal() { - this.setState({ - showLeaveChannelModal: false - }); - } - - createLeaveChannelModal() { - const title = ( - {this.state.channel.display_name} - }} - /> - ); - - const message = ( - {this.state.channel.display_name} - }} - /> - ); - - const buttonClass = 'btn btn-danger'; - const button = ( - - ); - - return ( - ChannelActions.leaveChannel(this.state.channel.id)} - onCancel={this.hideLeaveChannelModal} - /> - ); - } - render() { const flagIcon = Constants.FLAG_ICON_SVG; const pinIcon = Constants.PIN_ICON_SVG; @@ -885,8 +831,6 @@ export default class ChannelHeader extends React.Component { ); } - const leaveChannelModal = this.createLeaveChannelModal(); - let pinnedIconClass = 'channel-header__icon'; if (this.state.pinsOpen) { pinnedIconClass += ' active'; @@ -1001,7 +945,6 @@ export default class ChannelHeader extends React.Component { {editHeaderModal} {editPurposeModal} {channelMembersModal} - {leaveChannelModal} {this.state.channel.display_name} + }} + /> + ); + + message = ( + {this.state.channel.display_name} + }} + /> + ); + } + + const buttonClass = 'btn btn-danger'; + const button = ( + + ); + + return ( + + ); + } +} + +LeavePrivateChannelModal.propTypes = { + intl: intlShape.isRequired +}; + +export default injectIntl(LeavePrivateChannelModal); diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index 81959f352..0217dc15c 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -12,7 +12,6 @@ import ChannelMembersModal from './channel_members_modal.jsx'; import ChannelNotificationsModal from './channel_notifications_modal.jsx'; import DeleteChannelModal from './delete_channel_modal.jsx'; import RenameChannelModal from './rename_channel_modal.jsx'; -import ConfirmModal from './confirm_modal.jsx'; import ToggleModalButton from './toggle_modal_button.jsx'; import StatusIcon from './status_icon.jsx'; @@ -75,9 +74,6 @@ export default class Navbar extends React.Component { this.openDirectMessageModal = this.openDirectMessageModal.bind(this); this.getPinnedPosts = this.getPinnedPosts.bind(this); - this.createLeaveChannelModal = this.createLeaveChannelModal.bind(this); - this.hideLeaveChannelModal = this.hideLeaveChannelModal.bind(this); - const state = this.getStateFromStores(); state.showEditChannelPurposeModal = false; state.showEditChannelHeaderModal = false; @@ -97,8 +93,7 @@ export default class Navbar extends React.Component { users: [], userCount: ChannelStore.getCurrentStats().member_count, currentUser: UserStore.getCurrentUser(), - isFavorite: channel && ChannelUtils.isFavoriteChannel(channel), - showLeaveChannelModal: false + isFavorite: channel && ChannelUtils.isFavoriteChannel(channel) }; } @@ -139,9 +134,7 @@ export default class Navbar extends React.Component { handleLeave() { if (this.state.channel.type === Constants.PRIVATE_CHANNEL) { - this.setState({ - showLeaveChannelModal: true - }); + GlobalActions.showLeavePrivateChannelModal(this.state.channel); } else { ChannelActions.leaveChannel(this.state.channel.id); } @@ -739,54 +732,6 @@ export default class Navbar extends React.Component { return buttons; } - hideLeaveChannelModal() { - this.setState({ - showLeaveChannelModal: false - }); - } - - createLeaveChannelModal() { - const title = ( - {this.state.channel.display_name} - }} - /> - ); - - const message = ( - {this.state.channel.display_name} - }} - /> - ); - - const buttonClass = 'btn btn-danger'; - const button = ( - - ); - - return ( - ChannelActions.leaveChannel(this.state.channel.id)} - onCancel={this.hideLeaveChannelModal} - /> - ); - } - getTeammateStatus() { const channel = this.state.channel; @@ -961,8 +906,6 @@ export default class Navbar extends React.Component { var channelMenuDropdown = this.createDropdown(channel, channelTitle, isSystemAdmin, isTeamAdmin, isChannelAdmin, isDirect, isGroup, popoverContent); - const leaveChannelModal = this.createLeaveChannelModal(); - return (
); diff --git a/webapp/stores/modal_store.jsx b/webapp/stores/modal_store.jsx index 34bab780c..66be37b25 100644 --- a/webapp/stores/modal_store.jsx +++ b/webapp/stores/modal_store.jsx @@ -44,6 +44,7 @@ class ModalStoreClass extends EventEmitter { case ActionTypes.TOGGLE_CHANNEL_HEADER_UPDATE_MODAL: case ActionTypes.TOGGLE_CHANNEL_PURPOSE_UPDATE_MODAL: case ActionTypes.TOGGLE_CHANNEL_NAME_UPDATE_MODAL: + case ActionTypes.TOGGLE_LEAVE_PRIVATE_CHANNEL_MODAL: this.emit(type, value, args); break; } diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index e9246fdaf..60151fc8e 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -180,6 +180,7 @@ export const ActionTypes = keyMirror({ TOGGLE_CHANNEL_HEADER_UPDATE_MODAL: null, TOGGLE_CHANNEL_PURPOSE_UPDATE_MODAL: null, TOGGLE_CHANNEL_NAME_UPDATE_MODAL: null, + TOGGLE_LEAVE_PRIVATE_CHANNEL_MODAL: null, SUGGESTION_PRETEXT_CHANGED: null, SUGGESTION_RECEIVED_SUGGESTIONS: null, -- cgit v1.2.3-1-g7c22