|
diff --git a/actions/websocket_actions.test.jsx b/actions/websocket_actions.test.jsx |
|
index 15925cb9e..e3cd431d2 100644 |
|
--- a/actions/websocket_actions.test.jsx |
|
+++ b/actions/websocket_actions.test.jsx |
|
@@ -565,7 +565,7 @@ describe('handlePluginEnabled/handlePluginDisabled', () => { |
|
|
|
// Pretend to be a browser, invoke onload |
|
mockScript.onload(); |
|
- expect(initialize).toHaveBeenCalledWith(expect.anything(), store); |
|
+ expect(initialize).toHaveBeenCalledWith(expect.anything(), store, browserHistory); |
|
const registery = initialize.mock.calls[0][0]; |
|
const mockComponent = 'mockRootComponent'; |
|
registery.registerRootComponent(mockComponent); |
|
@@ -617,7 +617,7 @@ describe('handlePluginEnabled/handlePluginDisabled', () => { |
|
|
|
// Pretend to be a browser, invoke onload |
|
mockScript.onload(); |
|
- expect(initialize).toHaveBeenCalledWith(expect.anything(), store); |
|
+ expect(initialize).toHaveBeenCalledWith(expect.anything(), store, browserHistory); |
|
const registry = initialize.mock.calls[0][0]; |
|
const mockComponent = 'mockRootComponent'; |
|
registry.registerRootComponent(mockComponent); |
|
@@ -639,7 +639,7 @@ describe('handlePluginEnabled/handlePluginDisabled', () => { |
|
expect(document.createElement).toHaveBeenCalledTimes(2); |
|
|
|
mockScript.onload(); |
|
- expect(initialize).toHaveBeenCalledWith(expect.anything(), store); |
|
+ expect(initialize).toHaveBeenCalledWith(expect.anything(), store, browserHistory); |
|
expect(initialize).toHaveBeenCalledTimes(2); |
|
const registry2 = initialize.mock.calls[0][0]; |
|
const mockComponent2 = 'mockRootComponent2'; |
|
diff --git a/components/channel_header/index.js b/components/channel_header/index.js |
|
index 74d63a8a9..792eeb7a4 100644 |
|
--- a/components/channel_header/index.js |
|
+++ b/components/channel_header/index.js |
|
@@ -13,7 +13,7 @@ import {getCustomEmojisInText} from 'mattermost-redux/actions/emojis'; |
|
import {General} from 'mattermost-redux/constants'; |
|
import { |
|
getCurrentChannel, |
|
- getMyCurrentChannelMembership, |
|
+ getMyChannelMember, |
|
isCurrentChannelFavorite, |
|
isCurrentChannelMuted, |
|
isCurrentChannelReadOnly, |
|
@@ -62,7 +62,7 @@ function makeMapStateToProps() { |
|
return { |
|
teamId: getCurrentTeamId(state), |
|
channel, |
|
- channelMember: getMyCurrentChannelMembership(state), |
|
+ channelMember: getMyChannelMember(state, channel.id), |
|
currentUser: user, |
|
dmUser, |
|
gmMembers, |
|
diff --git a/components/channel_layout/app_route/app_route.jsx b/components/channel_layout/app_route/app_route.jsx |
|
new file mode 100644 |
|
index 000000000..082832c9d |
|
--- /dev/null |
|
+++ b/components/channel_layout/app_route/app_route.jsx |
|
@@ -0,0 +1,60 @@ |
|
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. |
|
+// See LICENSE.txt for license information. |
|
+ |
|
+import $ from 'jquery'; |
|
+import React from 'react'; |
|
+import PropTypes from 'prop-types'; |
|
+ |
|
+import * as UserAgent from 'utils/user_agent.jsx'; |
|
+import Pluggable from 'plugins/pluggable'; |
|
+import ChannelHeader from 'components/channel_header'; |
|
+ |
|
+export default class AppRouter extends React.PureComponent { |
|
+ static propTypes = { |
|
+ |
|
+ /* |
|
+ * Object from react-router |
|
+ */ |
|
+ match: PropTypes.shape({ |
|
+ params: PropTypes.shape({ |
|
+ identifier: PropTypes.string.isRequired, |
|
+ team: PropTypes.string.isRequired, |
|
+ channel: PropTypes.string, |
|
+ }).isRequired, |
|
+ }).isRequired, |
|
+ channelId: PropTypes.string, |
|
+ actions: PropTypes.shape({ |
|
+ selectChannel: PropTypes.func.isRequired, |
|
+ }).isRequired, |
|
+ } |
|
+ |
|
+ componentDidMount() { |
|
+ if (this.props.channelId) { |
|
+ this.props.actions.selectChannel(this.props.channelId); |
|
+ } |
|
+ $('body').addClass('app__body'); |
|
+ |
|
+ // IE Detection |
|
+ if (UserAgent.isInternetExplorer() || UserAgent.isEdge()) { |
|
+ $('body').addClass('browser--ie'); |
|
+ } |
|
+ } |
|
+ |
|
+ componentWillUnmount() { |
|
+ $('body').removeClass('app__body'); |
|
+ } |
|
+ |
|
+ render() { |
|
+ return ( |
|
+ <div className='app__content'> |
|
+ {this.props.channelId && <ChannelHeader channelId={this.props.channelId}/>} |
|
+ <Pluggable |
|
+ pluggableName={'App.' + this.props.match.params.identifier} |
|
+ teamName={this.props.match.params.team} |
|
+ channelName={this.props.match.params.channel} |
|
+ /> |
|
+ </div> |
|
+ ); |
|
+ } |
|
+} |
|
+ |
|
diff --git a/components/channel_layout/app_route/index.js b/components/channel_layout/app_route/index.js |
|
new file mode 100644 |
|
index 000000000..0f665ec4b |
|
--- /dev/null |
|
+++ b/components/channel_layout/app_route/index.js |
|
@@ -0,0 +1,28 @@ |
|
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. |
|
+// See LICENSE.txt for license information. |
|
+ |
|
+import {connect} from 'react-redux'; |
|
+import {withRouter} from 'react-router-dom'; |
|
+import {bindActionCreators} from 'redux'; |
|
+ |
|
+import {getChannelByName} from 'mattermost-redux/selectors/entities/channels'; |
|
+import {selectChannel} from 'mattermost-redux/actions/channels'; |
|
+ |
|
+import AppRoute from './app_route.jsx'; |
|
+ |
|
+function mapsStateToProps(state, ownProps) { |
|
+ const channelId = (getChannelByName(state, ownProps.match.params.channel) || {}).id; |
|
+ return { |
|
+ channelId, |
|
+ }; |
|
+} |
|
+ |
|
+function mapDispatchToProps(dispatch) { |
|
+ return { |
|
+ actions: bindActionCreators({ |
|
+ selectChannel, |
|
+ }, dispatch), |
|
+ }; |
|
+} |
|
+ |
|
+export default withRouter(connect(mapsStateToProps, mapDispatchToProps)(AppRoute)); |
|
diff --git a/components/channel_layout/center_channel/center_channel.jsx b/components/channel_layout/center_channel/center_channel.jsx |
|
index 889392a76..2ccca34fc 100644 |
|
--- a/components/channel_layout/center_channel/center_channel.jsx |
|
+++ b/components/channel_layout/center_channel/center_channel.jsx |
|
@@ -9,6 +9,7 @@ import classNames from 'classnames'; |
|
import PermalinkView from 'components/permalink_view'; |
|
import ChannelHeaderMobile from 'components/channel_header_mobile'; |
|
import ChannelIdentifierRouter from 'components/channel_layout/channel_identifier_router'; |
|
+import AppRouter from 'components/channel_layout/app_route'; |
|
|
|
export default class CenterChannel extends React.PureComponent { |
|
static propTypes = { |
|
@@ -61,6 +62,14 @@ export default class CenterChannel extends React.PureComponent { |
|
/> |
|
)} |
|
/> |
|
+ <Route |
|
+ path={'/:team/apps/:identifier'} |
|
+ component={AppRouter} |
|
+ /> |
|
+ <Route |
|
+ path={'/:team/channel-apps/:channel/:identifier'} |
|
+ component={AppRouter} |
|
+ /> |
|
<Route |
|
path={'/:team/:path(channels|messages)/:identifier'} |
|
component={ChannelIdentifierRouter} |
|
diff --git a/components/sidebar/index.js b/components/sidebar/index.js |
|
index d906e7897..220808a6c 100644 |
|
--- a/components/sidebar/index.js |
|
+++ b/components/sidebar/index.js |
|
@@ -67,6 +67,7 @@ function mapStateToProps(state) { |
|
canCreatePrivateChannel, |
|
isOpen: getIsLhsOpen(state), |
|
unreads: getUnreads(state), |
|
+ pluginApps: state.plugins.components.TeamApp, |
|
}; |
|
} |
|
|
|
diff --git a/components/sidebar/sidebar.jsx b/components/sidebar/sidebar.jsx |
|
index 85c541c28..d50cea5ca 100644 |
|
--- a/components/sidebar/sidebar.jsx |
|
+++ b/components/sidebar/sidebar.jsx |
|
@@ -133,6 +133,8 @@ export default class Sidebar extends React.PureComponent { |
|
*/ |
|
channelSwitcherOption: PropTypes.bool.isRequired, |
|
|
|
+ pluginApps: PropTypes.array.isRequired, |
|
+ |
|
actions: PropTypes.shape({ |
|
close: PropTypes.func.isRequired, |
|
switchToChannelById: PropTypes.func.isRequired, |
|
@@ -550,6 +552,7 @@ export default class Sidebar extends React.PureComponent { |
|
const {orderedChannelIds} = this.state; |
|
|
|
const sectionsToHide = [SidebarChannelGroups.UNREADS, SidebarChannelGroups.FAVORITE]; |
|
+ const pluginApps = this.props.pluginApps && this.props.pluginApps.filter((p) => p.show()); |
|
|
|
return ( |
|
<Scrollbars |
|
@@ -567,6 +570,24 @@ export default class Sidebar extends React.PureComponent { |
|
id='sidebarChannelContainer' |
|
className='nav-pills__container' |
|
> |
|
+ {pluginApps && pluginApps.length > 0 && |
|
+ <ul |
|
+ key='apps' |
|
+ className='nav nav-pills nav-stacked' |
|
+ > |
|
+ <li> |
|
+ <h4 id='apps'> |
|
+ <FormattedMessage |
|
+ id={'applications'} |
|
+ defaultMessage={'Applications'} |
|
+ /> |
|
+ </h4> |
|
+ </li> |
|
+ <Pluggable |
|
+ pluggableName='TeamApp' |
|
+ teamName={this.props.currentTeam.name} |
|
+ /> |
|
+ </ul>} |
|
{orderedChannelIds.map((sec) => { |
|
const section = { |
|
type: sec.type, |
|
diff --git a/components/sidebar/sidebar.test.jsx b/components/sidebar/sidebar.test.jsx |
|
index f040d4c28..46e9d9840 100644 |
|
--- a/components/sidebar/sidebar.test.jsx |
|
+++ b/components/sidebar/sidebar.test.jsx |
|
@@ -136,6 +136,7 @@ describe('component/sidebar/sidebar_channel/SidebarChannel', () => { |
|
switchToChannelById: jest.fn(), |
|
openModal: jest.fn(), |
|
}, |
|
+ pluginApps: [], |
|
redirectChannel: 'default-channel', |
|
canCreatePublicChannel: true, |
|
canCreatePrivateChannel: true, |
|
diff --git a/i18n/en.json b/i18n/en.json |
|
index 4837dad5a..f4a0e058b 100644 |
|
--- a/i18n/en.json |
|
+++ b/i18n/en.json |
|
@@ -1722,6 +1722,7 @@ |
|
"app.channel.post_update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {old})", |
|
"app.channel.post_update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {old} to: {new}", |
|
"app.channel.post_update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {new}", |
|
+ "applications": "Applications", |
|
"app.plugin.marketplace_plugins.app_error": "Error connecting to the marketplace server. Please check your settings in the [System Console](/admin_console/plugins/plugin_management).", |
|
"archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.", |
|
"atmos/camo": "atmos/camo", |
|
diff --git a/plugins/channel_header_plug/channel_header_plug.jsx b/plugins/channel_header_plug/channel_header_plug.jsx |
|
index a1b7daeab..a68f7832a 100644 |
|
--- a/plugins/channel_header_plug/channel_header_plug.jsx |
|
+++ b/plugins/channel_header_plug/channel_header_plug.jsx |
|
@@ -117,9 +117,13 @@ export default class ChannelHeaderPlug extends React.PureComponent { |
|
} |
|
|
|
createButton = (plug) => { |
|
+ let activeClass = ''; |
|
+ if (plug.active && plug.active()) { |
|
+ activeClass = ' active'; |
|
+ } |
|
return ( |
|
<HeaderIconWrapper |
|
- buttonClass='channel-header__icon style--none' |
|
+ buttonClass={'channel-header__icon style--none' + activeClass} |
|
iconComponent={plug.icon} |
|
onClick={() => plug.action(this.props.channel, this.props.channelMember)} |
|
buttonId={plug.id} |
|
@@ -187,7 +191,13 @@ export default class ChannelHeaderPlug extends React.PureComponent { |
|
} |
|
|
|
render() { |
|
- const components = this.props.components || []; |
|
+ let components = this.props.components || []; |
|
+ components = components.filter((c) => { |
|
+ if (c.show) { |
|
+ return c.show(); |
|
+ } |
|
+ return true; |
|
+ }); |
|
|
|
if (components.length === 0) { |
|
return null; |
|
diff --git a/plugins/index.js b/plugins/index.js |
|
index ef725f7f1..c7649d743 100644 |
|
--- a/plugins/index.js |
|
+++ b/plugins/index.js |
|
@@ -7,6 +7,7 @@ import {Client4} from 'mattermost-redux/client'; |
|
|
|
import store from 'stores/redux_store.jsx'; |
|
import {ActionTypes} from 'utils/constants.jsx'; |
|
+import {browserHistory} from 'utils/browser_history.jsx'; |
|
import {getSiteURL} from 'utils/url.jsx'; |
|
import PluginRegistry from 'plugins/registry'; |
|
import {unregisterAllPluginWebSocketEvents, unregisterPluginReconnectHandler} from 'actions/websocket_actions.jsx'; |
|
@@ -125,7 +126,7 @@ function initializePlugin(manifest) { |
|
const plugin = window.plugins[manifest.id]; |
|
const registry = new PluginRegistry(manifest.id); |
|
if (plugin && plugin.initialize) { |
|
- plugin.initialize(registry, store); |
|
+ plugin.initialize(registry, store, browserHistory); |
|
} |
|
} |
|
|
|
diff --git a/plugins/pluggable/pluggable.jsx b/plugins/pluggable/pluggable.jsx |
|
index ab2bc20f7..7f388d88f 100644 |
|
--- a/plugins/pluggable/pluggable.jsx |
|
+++ b/plugins/pluggable/pluggable.jsx |
|
@@ -65,6 +65,9 @@ export default class Pluggable extends React.PureComponent { |
|
} |
|
|
|
const content = pluginComponents.map((p) => { |
|
+ if (p.show && !p.show()) { |
|
+ return null; |
|
+ } |
|
const PluginComponent = p.component; |
|
return ( |
|
<PluginComponent |
|
diff --git a/plugins/registry.js b/plugins/registry.js |
|
index 663254575..98fb3c056 100644 |
|
--- a/plugins/registry.js |
|
+++ b/plugins/registry.js |
|
@@ -78,6 +78,30 @@ export default class PluginRegistry { |
|
return dispatchPluginComponentAction('LeftSidebarHeader', this.id, component); |
|
} |
|
|
|
+ // Register a in the list of apps. |
|
+ // Accepts a React component. Returns a unique identifier. |
|
+ registerTeamAppComponent(component, show = () => true) { |
|
+ const id = generateId(); |
|
+ store.dispatch({ |
|
+ type: ActionTypes.RECEIVED_PLUGIN_COMPONENT, |
|
+ name: 'TeamApp', |
|
+ data: { |
|
+ id, |
|
+ pluginId: this.id, |
|
+ component, |
|
+ show, |
|
+ }, |
|
+ }); |
|
+ |
|
+ return id; |
|
+ } |
|
+ |
|
+ // Register in the app visualization in the center panel. |
|
+ // Accepts an id for the url and a React component. Returns a unique identifier. |
|
+ registerAppCenterComponent(id, component) { |
|
+ return dispatchPluginComponentAction('App.' + id, this.id, component); |
|
+ } |
|
+ |
|
// Register a component fixed to the bottom of the team sidebar. Does not render if |
|
// user is only on one team and the team sidebar is not shown. |
|
// Accepts a React component. Returns a unique identifier. |
|
@@ -104,7 +128,7 @@ export default class PluginRegistry { |
|
// - action - a function called when the button is clicked, passed the channel and channel member as arguments |
|
// - dropdown_text - string or React element shown for the dropdown button description |
|
// - tooltip_text - string shown for tooltip appear on hover |
|
- registerChannelHeaderButtonAction(icon, action, dropdownText, tooltipText) { |
|
+ registerChannelHeaderButtonAction(icon, action, dropdownText, tooltipText, show = () => true, active = () => false) { |
|
const id = generateId(); |
|
|
|
const data = { |
|
@@ -114,6 +138,8 @@ export default class PluginRegistry { |
|
action, |
|
dropdownText: resolveReactElement(dropdownText), |
|
tooltipText, |
|
+ show, |
|
+ active, |
|
}; |
|
|
|
store.dispatch({ |
|
diff --git a/sass/layout/_sidebar-left.scss b/sass/layout/_sidebar-left.scss |
|
index 17c7aa7c1..e6462c36a 100644 |
|
--- a/sass/layout/_sidebar-left.scss |
|
+++ b/sass/layout/_sidebar-left.scss |
|
@@ -433,6 +433,10 @@ |
|
@include transition-timing-function(ease-in-out); |
|
} |
|
} |
|
+ #apps { |
|
+ margin: 1em 1em .6em 1em; |
|
+ text-transform: uppercase; |
|
+ } |
|
} |
|
|
|
.channel-loading-gif { |