diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index fee6fe025..a069984dc 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -6,6 +6,8 @@ import t from 'coral-framework/services/i18n'; import {TabBar, Tab, TabContent, TabPane} from 'coral-ui'; import ProfileContainer from 'coral-settings/containers/ProfileContainer'; +import Popup from 'coral-framework/components/Popup'; +import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty'; import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer'; import cn from 'classnames'; @@ -26,12 +28,24 @@ export default class Embed extends React.Component { }; render() { - const {activeTab, commentId} = this.props; + const {activeTab, commentId, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props; const {user} = this.props.auth; const hasHighlightedComment = !!commentId; return (
+ + +
- {getSlotComponents('streamTabs').map((PluginComponent) => ( + {getSlotComponents('streamTabs', reduxState, streamTabProps).map((PluginComponent) => ( ))} @@ -222,12 +222,10 @@ class Stream extends React.Component { - {getSlotComponents('streamTabPanes').map((PluginComponent) => ( + {getSlotComponents('streamTabPanes', reduxState, streamTabProps).map((PluginComponent) => ( ))} diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index a5c6ba1a6..aa0700ad5 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -19,7 +19,7 @@ import t from 'coral-framework/services/i18n'; import {setActiveTab} from '../actions/embed'; -const {logout, checkLogin} = authActions; +const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions; const {fetchAssetSuccess} = assetActions; class EmbedContainer extends React.Component { @@ -185,6 +185,9 @@ const mapDispatchToProps = (dispatch) => setActiveTab, fetchAssetSuccess, addNotification, + focusSignInDialog, + blurSignInDialog, + hideSignInDialog, }, dispatch ); diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index a6fb4656f..6be8b9ee6 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -24,6 +24,7 @@ import { insertFetchedCommentsIntoEmbedQuery, nest, } from '../graphql/utils'; +import omit from 'lodash/omit'; const {showSignInDialog} = authActions; const {addNotification} = notificationActions; @@ -307,7 +308,9 @@ const mapStateToProps = (state) => ({ previousTab: state.embed.previousTab, activeStreamTab: state.stream.activeTab, previousStreamTab: state.stream.previousTab, - commentClassNames: state.stream.commentClassNames + commentClassNames: state.stream.commentClassNames, + pluginConfig: state.config.plugin_config, + reduxState: omit(state, 'apollo'), }); const mapDispatchToProps = (dispatch) => diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 732a11ebe..d3c29dc3c 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -7,45 +7,33 @@ import pym from '../services/pym'; import {resetWebsocket} from 'coral-framework/services/client'; import t from 'coral-framework/services/i18n'; -import {isSlotEmpty} from 'plugin-api/beta/client/services'; -export const showSignInDialog = () => (dispatch, getState) => { - if (isSlotEmpty('login')) { - return; - } - const signInPopUp = window.open( - '/embed/stream/login', - 'Login', - 'menubar=0,resizable=0,width=500,height=550,top=200,left=500' - ); +export const showSignInDialog = () => ({ + type: actions.SHOW_SIGNIN_DIALOG, +}); - // Workaround odd behavior in older WebKit versions, where - // onunload is called twice. (Encountered in IOS 8.3) - let loaded = false; - signInPopUp.onload = () => { - loaded = true; - - // Fire some actions inside the popups reducer, to initialize required state. - const required = getState().asset.toJS().settings.requireEmailConfirmation; - const redirectUri = getState().auth.toJS().redirectUri; - signInPopUp.coralStore.dispatch(setRequireEmailVerification(required)); - signInPopUp.coralStore.dispatch(setRedirectUri(redirectUri)); - }; - - // Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari. - signInPopUp.onunload = () => { - if (loaded) { - dispatch(checkLogin()); - } - }; - - dispatch({type: actions.SHOW_SIGNIN_DIALOG}); -}; export const hideSignInDialog = () => (dispatch) => { + if (window.opener && window.opener !== window) { + + // TODO: We need to address this when we refactor the + // login popup out of the embed. + + // we are in a popup + window.close(); + } else { + dispatch(checkLogin()); + } dispatch({type: actions.HIDE_SIGNIN_DIALOG}); - window.close(); }; +export const focusSignInDialog = () => ({ + type: actions.FOCUS_SIGNIN_DIALOG, +}); + +export const blurSignInDialog = () => ({ + type: actions.BLUR_SIGNIN_DIALOG, +}); + export const createUsernameRequest = () => ({ type: actions.CREATE_USERNAME_REQUEST }); diff --git a/client/coral-framework/components/IfSlotIsEmpty.js b/client/coral-framework/components/IfSlotIsEmpty.js new file mode 100644 index 000000000..424a59bc7 --- /dev/null +++ b/client/coral-framework/components/IfSlotIsEmpty.js @@ -0,0 +1,25 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import {isSlotEmpty} from 'coral-framework/helpers/plugins'; +import PropTypes from 'prop-types'; +import omit from 'lodash/omit'; + +function IfSlotIsEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) { + return ( + + {isSlotEmpty(slot, reduxState, rest) ? children : null} + + ); +} + +IfSlotIsEmpty.propTypes = { + slot: PropTypes.string, + className: PropTypes.string, +}; + +const mapStateToProps = (state) => ({ + reduxState: omit(state, 'apollo'), +}); + +export default connect(mapStateToProps, null)(IfSlotIsEmpty); + diff --git a/client/coral-framework/components/IfSlotIsNotEmpty.js b/client/coral-framework/components/IfSlotIsNotEmpty.js new file mode 100644 index 000000000..3a41fffbd --- /dev/null +++ b/client/coral-framework/components/IfSlotIsNotEmpty.js @@ -0,0 +1,25 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import {isSlotEmpty} from 'coral-framework/helpers/plugins'; +import PropTypes from 'prop-types'; +import omit from 'lodash/omit'; + +function IfSlotIsNotEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) { + return ( + + {!isSlotEmpty(slot, reduxState, rest) ? children : null} + + ); +} + +IfSlotIsNotEmpty.propTypes = { + slot: PropTypes.string, + className: PropTypes.string, +}; + +const mapStateToProps = (state) => ({ + reduxState: omit(state, 'apollo'), +}); + +export default connect(mapStateToProps, null)(IfSlotIsNotEmpty); + diff --git a/client/coral-framework/components/Popup.js b/client/coral-framework/components/Popup.js new file mode 100644 index 000000000..b9393a36d --- /dev/null +++ b/client/coral-framework/components/Popup.js @@ -0,0 +1,152 @@ +import {Component} from 'react'; +import PropTypes from 'prop-types'; + +export default class Popup extends Component { + ref = null; + detectCloseInterval = null; + + constructor(props) { + super(props); + + if (props.open) { + this.openWindow(props); + } + } + + openWindow(props = this.props) { + this.ref = window.open( + props.href, + props.title, + props.features, + ); + + this.setCallbacks(); + } + + setCallbacks() { + this.ref.onload = () => { + clearInterval(this.detectCloseInterval); + this.onLoad(); + }; + + this.ref.onfocus = () => { + this.onFocus(); + }; + + this.ref.onblur = () => { + this.onBlur(); + }; + + // Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari. + this.ref.onunload = () => { + this.onUnload(); + + const interval = setInterval(() => { + if (this.ref.onload === null) { + this.setCallbacks(); + clearInterval(interval); + } + }, 50); + + this.detectCloseInterval = setInterval(() => { + if (this.ref.closed) { + clearInterval(this.detectCloseInterval); + this.onClose(); + } + }, 50); + }; + } + + closeWindow() { + if (this.ref) { + if (!this.ref.closed) { + this.ref.close(); + } + this.ref = null; + } + } + + focusWindow() { + if (this.ref && !this.ref.closed) { + this.ref.focus(); + } + } + + blurWindow() { + if (this.ref && !this.ref.closed) { + this.ref.blur(); + } + } + + onLoad = () => { + if (this.props.onLoad) { + this.props.onLoad(); + } + } + + onUnload = () => { + if (this.props.onUnload) { + this.props.onUnload(); + } + } + + onClose = () => { + if (this.props.onClose) { + this.props.onClose(); + } + } + + onFocus = () => { + if (this.props.onFocus) { + this.props.onFocus(); + } + } + + onBlur = () => { + if (this.props.onBlur) { + this.props.onBlur(); + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps.open && !this.ref) { + this.openWindow(nextProps); + } + + if (this.props.open && !nextProps.open) { + this.closeWindow(); + } + + if (!this.props.focus && nextProps.focus) { + this.focusWindow(); + } + + if (this.props.focus && !nextProps.focus) { + this.blurWindow(); + } + + if (this.props.href !== nextProps.href) { + this.ref.location.href = nextProps.href; + } + } + + componentWillUnmount() { + this.closeWindow(); + } + + render() { + return null; + } +} + +Popup.propTypes = { + open: PropTypes.bool, + focus: PropTypes.bool, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + onLoad: PropTypes.func, + onUnload: PropTypes.func, + onClose: PropTypes.func, + href: PropTypes.string.isRequired, + features: PropTypes.string, +}; diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index ca246591b..b1df6647a 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -3,15 +3,17 @@ import cn from 'classnames'; import styles from './Slot.css'; import {connect} from 'react-redux'; import {getSlotElements} from 'coral-framework/helpers/plugins'; +import omit from 'lodash/omit'; -function Slot ({fill, inline = false, className, plugin_config: config, defaultComponent: DefaultComponent, ...rest}) { - let children = getSlotElements(fill, {...rest, config}); +function Slot ({fill, inline = false, className, reduxState, defaultComponent: DefaultComponent, ...rest}) { + let children = getSlotElements(fill, reduxState, rest); + const pluginConfig = reduxState.config.pluginConfig || {}; if (children.length === 0 && DefaultComponent) { children = ; } return ( -
+
{children}
); @@ -21,7 +23,9 @@ Slot.propTypes = { fill: React.PropTypes.string }; -const mapStateToProps = ({config: {plugin_config = {}}}) => ({plugin_config}); +const mapStateToProps = (state) => ({ + reduxState: omit(state, 'apollo'), +}); export default connect(mapStateToProps, null)(Slot); diff --git a/client/coral-framework/constants/auth.js b/client/coral-framework/constants/auth.js index 57bde6f3f..324f75f44 100644 --- a/client/coral-framework/constants/auth.js +++ b/client/coral-framework/constants/auth.js @@ -3,6 +3,8 @@ export const CLEAN_STATE = 'CLEAN_STATE'; export const SHOW_SIGNIN_DIALOG = 'SHOW_SIGNIN_DIALOG'; export const HIDE_SIGNIN_DIALOG = 'HIDE_SIGNIN_DIALOG'; +export const FOCUS_SIGNIN_DIALOG = 'FOCUS_SIGNIN_DIALOG'; +export const BLUR_SIGNIN_DIALOG = 'BLUR_SIGNIN_DIALOG'; export const CREATE_USERNAME_REQUEST = 'CREATE_USERNAME_REQUEST'; export const CREATE_USERNAME_SUCCESS = 'CREATE_USERNAME_SUCCESS'; diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index d60329baa..cc8147c19 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -6,32 +6,42 @@ import plugins from 'pluginsConfig'; import flatten from 'lodash/flatten'; import flattenDeep from 'lodash/flattenDeep'; import {loadTranslations} from 'coral-framework/services/i18n'; -import {injectReducers, getStore} from 'coral-framework/services/store'; +import {injectReducers} from 'coral-framework/services/store'; import camelize from './camelize'; -export function getSlotComponents(slot) { - const pluginConfig = getStore().getState().config.plugin_config; - +export function getSlotComponents(slot, reduxState, props = {}) { + const pluginConfig = reduxState.config.pluginConfig || {}; return flatten(plugins - // Filter out components that have slots and have been disabled in `plugin_config` + // Filter out components that have slots and have been disabled in `plugin_config` .filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components)) .filter((o) => o.module.slots[slot]) .map((o) => o.module.slots[slot]) - ); + ) + .filter((component) => { + if(!component.isExcluded) { + return true; + } + let resolvedProps = {...props, config: pluginConfig}; + if (component.mapStateToProps) { + resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)}; + } + return !component.isExcluded(resolvedProps); + }); } -export function isSlotEmpty(slot) { - return getSlotComponents(slot).length === 0; +export function isSlotEmpty(slot, reduxState, props) { + return getSlotComponents(slot, reduxState, props).length === 0; } /** * Returns React Elements for given slot. */ -export function getSlotElements(slot, props = {}) { - return getSlotComponents(slot) - .map((component, i) => React.createElement(component, {key: i, ...props})); +export function getSlotElements(slot, reduxState, props = {}) { + const pluginConfig = reduxState.config.pluginConfig || {}; + return getSlotComponents(slot, reduxState, props) + .map((component, i) => React.createElement(component, {key: i, ...props, config: pluginConfig})); } export function getSlotFragments(slot, part) { diff --git a/client/coral-framework/hocs/connect.js b/client/coral-framework/hocs/connect.js new file mode 100644 index 000000000..c49fe8380 --- /dev/null +++ b/client/coral-framework/hocs/connect.js @@ -0,0 +1,6 @@ +import {connect} from 'react-redux'; + +export default (mapStateToProps, ...rest) => (BaseComponent) => { + BaseComponent.mapStateToProps = mapStateToProps; + return connect(mapStateToProps, ...rest)(BaseComponent); +}; diff --git a/client/coral-framework/hocs/excludeIf.js b/client/coral-framework/hocs/excludeIf.js new file mode 100644 index 000000000..ec2f0f27a --- /dev/null +++ b/client/coral-framework/hocs/excludeIf.js @@ -0,0 +1,4 @@ +export default (condition) => (BaseComponent) => { + BaseComponent.isExcluded = condition; + return BaseComponent; +}; diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index d62d62ae3..62e96444c 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -1,14 +1,5 @@ -import React from 'react'; -import {getDisplayName} from '../helpers/hoc'; - // TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38. -export default (fragments) => (WrappedComponent) => { - class WithFragments extends React.Component { - render() { - return ; - } - } - WithFragments.fragments = fragments; - WithFragments.displayName = `WithFragments(${getDisplayName(WrappedComponent)})`; - return WithFragments; +export default (fragments) => (BaseComponent) => { + BaseComponent.fragments = fragments; + return BaseComponent; }; diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 92d676abe..5481b7a5e 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -7,6 +7,7 @@ const initialState = Map({ loggedIn: false, user: null, showSignInDialog: false, + signInDialogFocus: false, showCreateUsernameDialog: false, checkedInitialLogin: false, view: 'SIGNIN', @@ -29,13 +30,21 @@ const purge = (user) => { export default function auth (state = initialState, action) { switch (action.type) { + case actions.FOCUS_SIGNIN_DIALOG: + return state + .set('signInDialogFocus', true); + case actions.BLUR_SIGNIN_DIALOG: + return state + .set('signInDialogFocus', false); case actions.SHOW_SIGNIN_DIALOG : return state - .set('showSignInDialog', true); + .set('showSignInDialog', true) + .set('signInDialogFocus', true); case actions.HIDE_SIGNIN_DIALOG : return state.merge(Map({ isLoading: false, showSignInDialog: false, + signInDialogFocus: false, view: 'SIGNIN', error: '', passwordRequestFailure: null, diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 7f3476cfd..78408f6c2 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -1,2 +1,4 @@ export {default as withReaction} from './withReaction'; export {default as withFragments} from 'coral-framework/hocs/withFragments'; +export {default as excludeIf} from 'coral-framework/hocs/excludeIf'; +export {default as connect} from 'coral-framework/hocs/connect'; diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js index 72de7f5c4..f7be6e701 100644 --- a/plugin-api/beta/client/hocs/withReaction.js +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -1,7 +1,7 @@ import React from 'react'; import get from 'lodash/get'; import uuid from 'uuid/v4'; -import {connect} from 'react-redux'; +import {connect} from 'plugin-api/beta/client/hocs'; import {bindActionCreators} from 'redux'; import {getDisplayName} from 'coral-framework/helpers/hoc'; import {compose, gql} from 'react-apollo'; diff --git a/plugin-api/beta/client/selectors/index.js b/plugin-api/beta/client/selectors/index.js new file mode 100644 index 000000000..09e25d453 --- /dev/null +++ b/plugin-api/beta/client/selectors/index.js @@ -0,0 +1 @@ +export const pluginConfigSelector = (state) => state.config.pluginConfig; diff --git a/plugin-api/beta/client/services/index.js b/plugin-api/beta/client/services/index.js index ddaa346ab..4e94a782a 100644 --- a/plugin-api/beta/client/services/index.js +++ b/plugin-api/beta/client/services/index.js @@ -1,3 +1,9 @@ export {t} from 'coral-framework/services/i18n'; export {can} from 'coral-framework/services/perms'; -export {isSlotEmpty} from 'coral-framework/helpers/plugins'; +import {isSlotEmpty as ise} from 'coral-framework/helpers/plugins'; + +// @TODO: Deprecated. +export function isSlotEmpty(...args) { + console.warn('A plugin is using `isSlotEmpty` which has been deprecated, please port to the new API using the `IfSlotIsEmpty` and `IfSlotIsNotEmpty` components.'); + return ise(...args); +} diff --git a/plugins/coral-plugin-offtopic/client/containers/OffTopicCheckbox.js b/plugins/coral-plugin-offtopic/client/containers/OffTopicCheckbox.js index 286fa8d9d..f8471344b 100644 --- a/plugins/coral-plugin-offtopic/client/containers/OffTopicCheckbox.js +++ b/plugins/coral-plugin-offtopic/client/containers/OffTopicCheckbox.js @@ -1,7 +1,7 @@ -import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {addTag, removeTag} from 'plugin-api/alpha/client/actions'; import {commentBoxTagsSelector} from 'plugin-api/alpha/client/selectors'; +import {connect} from 'plugin-api/beta/client/hocs'; import OffTopicCheckbox from '../components/OffTopicCheckbox'; const mapStateToProps = (state) => ({ diff --git a/plugins/coral-plugin-offtopic/client/containers/OffTopicFilter.js b/plugins/coral-plugin-offtopic/client/containers/OffTopicFilter.js index 98f5f7aea..6bca55638 100644 --- a/plugins/coral-plugin-offtopic/client/containers/OffTopicFilter.js +++ b/plugins/coral-plugin-offtopic/client/containers/OffTopicFilter.js @@ -1,4 +1,4 @@ -import {connect} from 'react-redux'; +import {connect} from 'plugin-api/beta/client/hocs'; import {bindActionCreators} from 'redux'; import {toggleCheckbox} from '../actions'; import {commentClassNamesSelector} from 'plugin-api/alpha/client/selectors'; diff --git a/plugins/coral-plugin-viewing-options/client/containers/ViewingOptions.js b/plugins/coral-plugin-viewing-options/client/containers/ViewingOptions.js index 767a9197d..cbe1e7fa3 100644 --- a/plugins/coral-plugin-viewing-options/client/containers/ViewingOptions.js +++ b/plugins/coral-plugin-viewing-options/client/containers/ViewingOptions.js @@ -1,4 +1,4 @@ -import {connect} from 'react-redux'; +import {connect} from 'plugin-api/beta/client/hocs'; import {bindActionCreators} from 'redux'; import ViewingOptions from '../components/ViewingOptions'; import {openViewingOptions, closeViewingOptions} from '../actions';