diff --git a/app/soapbox/actions/remote_timeline.js b/app/soapbox/actions/remote_timeline.js new file mode 100644 index 000000000..38249c009 --- /dev/null +++ b/app/soapbox/actions/remote_timeline.js @@ -0,0 +1,24 @@ +import { getSettings, changeSetting } from 'soapbox/actions/settings'; + +const getPinnedHosts = state => { + const settings = getSettings(state); + return settings.getIn(['remote_timeline', 'pinnedHosts']); +}; + +export function pinHost(host) { + return (dispatch, getState) => { + const state = getState(); + const pinnedHosts = getPinnedHosts(state); + + return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.add(host))); + }; +} + +export function unpinHost(host) { + return (dispatch, getState) => { + const state = getState(); + const pinnedHosts = getPinnedHosts(state); + + return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.delete(host))); + }; +} diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 5beba817a..47b153ee7 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -1,7 +1,7 @@ import { debounce } from 'lodash'; import { showAlertForError } from './alerts'; import { patchMe } from 'soapbox/actions/me'; -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { isLoggedIn } from 'soapbox/utils/auth'; import uuid from '../uuid'; import { createSelector } from 'reselect'; @@ -143,6 +143,10 @@ export const defaultSettings = ImmutableMap({ ImmutableMap({ id: 'HOME', uuid: uuid(), params: {} }), ImmutableMap({ id: 'NOTIFICATIONS', uuid: uuid(), params: {} }), ]), + + remote_timeline: ImmutableMap({ + pinnedHosts: ImmutableOrderedSet(), + }), }); export const getSettings = createSelector([ diff --git a/app/soapbox/features/public_timeline/index.js b/app/soapbox/features/public_timeline/index.js index dc204f0eb..a424ec988 100644 --- a/app/soapbox/features/public_timeline/index.js +++ b/app/soapbox/features/public_timeline/index.js @@ -7,6 +7,7 @@ import Column from '../../components/column'; import ColumnSettingsContainer from './containers/column_settings_container'; import HomeColumnHeader from '../../components/home_column_header'; import Accordion from 'soapbox/features/ui/components/accordion'; +import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker'; import { expandPublicTimeline } from '../../actions/timelines'; import { connectPublicStream } from '../../actions/streaming'; import { Link } from 'react-router-dom'; @@ -101,6 +102,7 @@ class CommunityTimeline extends React.PureComponent { + {showExplanationBox &&
} diff --git a/app/soapbox/features/remote_timeline/components/pinned_hosts_picker.js b/app/soapbox/features/remote_timeline/components/pinned_hosts_picker.js new file mode 100644 index 000000000..0305268b0 --- /dev/null +++ b/app/soapbox/features/remote_timeline/components/pinned_hosts_picker.js @@ -0,0 +1,51 @@ +'use strict'; + +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; +import { getSettings } from 'soapbox/actions/settings'; + +const mapStateToProps = state => { + const settings = getSettings(state); + + return { + pinnedHosts: settings.getIn(['remote_timeline', 'pinnedHosts']), + }; +}; + +class PinnedHostPicker extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + pinnedHosts: ImmutablePropTypes.orderedSet, + host: PropTypes.string, + }; + + render() { + const { pinnedHosts, host: activeHost } = this.props; + + if (!pinnedHosts || pinnedHosts.isEmpty()) return null; + + return ( +
+
+ {pinnedHosts.map(host => ( +
+ {host} +
+ ))} +
+
+ ); + } + +} + +export default connect(mapStateToProps)(PinnedHostPicker); diff --git a/app/soapbox/features/remote_timeline/index.js b/app/soapbox/features/remote_timeline/index.js index ee08192ff..5f22c4930 100644 --- a/app/soapbox/features/remote_timeline/index.js +++ b/app/soapbox/features/remote_timeline/index.js @@ -5,6 +5,7 @@ import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../components/column'; import HomeColumnHeader from '../../components/home_column_header'; +import PinnedHostsPicker from './components/pinned_hosts_picker'; import IconButton from 'soapbox/components/icon_button'; import { expandRemoteTimeline } from '../../actions/timelines'; import { connectRemoteStream } from '../../actions/streaming'; @@ -84,6 +85,7 @@ class RemoteTimeline extends React.PureComponent { return ( +
{ - const me = state.get('me'); - const account = state.getIn(['accounts', me]); + const settings = getSettings(state); return { instance: state.get('instance'), remoteInstance: getRemoteInstance(state, host), - isAdmin: isAdmin(account), + pinned: settings.getIn(['remote_timeline', 'pinnedHosts']).includes(host), }; }; @@ -38,40 +37,43 @@ class InstanceInfoPanel extends ImmutablePureComponent { host: PropTypes.string.isRequired, instance: ImmutablePropTypes.map, remoteInstance: ImmutablePropTypes.map, - isAdmin: PropTypes.bool, + pinned: PropTypes.bool, }; - handleEditFederation = e => { - const { dispatch, host } = this.props; - dispatch(openModal('EDIT_FEDERATION', { host })); + handlePinHost = e => { + const { dispatch, host, pinned } = this.props; + + if (!pinned) { + dispatch(pinHost(host)); + } else { + dispatch(unpinHost(host)); + } } makeMenu = () => { - const { intl } = this.props; + const { intl, host, pinned } = this.props; return [{ - text: intl.formatMessage(messages.editFederation), - action: this.handleEditFederation, + text: intl.formatMessage(pinned ? messages.unpinHost : messages.pinHost, { host }), + action: this.handlePinHost, }]; } render() { - const { remoteInstance, isAdmin } = this.props; + const { remoteInstance, pinned } = this.props; const menu = this.makeMenu(); + const icon = pinned ? 'thumb-tack' : 'globe-w'; return (
- + - + {remoteInstance.get('host')} - {isAdmin &&
+
-
} -
-
- +
); diff --git a/app/soapbox/features/ui/components/instance_moderation_panel.js b/app/soapbox/features/ui/components/instance_moderation_panel.js new file mode 100644 index 000000000..693940540 --- /dev/null +++ b/app/soapbox/features/ui/components/instance_moderation_panel.js @@ -0,0 +1,80 @@ +'use strict'; + +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { makeGetRemoteInstance } from 'soapbox/selectors'; +import InstanceRestrictions from 'soapbox/features/federation_restrictions/components/instance_restrictions'; +import DropdownMenu from 'soapbox/containers/dropdown_menu_container'; +import { openModal } from 'soapbox/actions/modal'; +import { isAdmin } from 'soapbox/utils/accounts'; + +const getRemoteInstance = makeGetRemoteInstance(); + +const messages = defineMessages({ + editFederation: { id: 'remote_instance.edit_federation', defaultMessage: 'Edit federation' }, +}); + +const mapStateToProps = (state, { host }) => { + const me = state.get('me'); + const account = state.getIn(['accounts', me]); + + return { + instance: state.get('instance'), + remoteInstance: getRemoteInstance(state, host), + isAdmin: isAdmin(account), + }; +}; + +export default @connect(mapStateToProps, null, null, { forwardRef: true }) +@injectIntl +class InstanceModerationPanel extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + host: PropTypes.string.isRequired, + instance: ImmutablePropTypes.map, + remoteInstance: ImmutablePropTypes.map, + isAdmin: PropTypes.bool, + }; + + handleEditFederation = e => { + const { dispatch, host } = this.props; + dispatch(openModal('EDIT_FEDERATION', { host })); + } + + makeMenu = () => { + const { intl } = this.props; + + return [{ + text: intl.formatMessage(messages.editFederation), + action: this.handleEditFederation, + }]; + } + + render() { + const { remoteInstance, isAdmin } = this.props; + const menu = this.makeMenu(); + + return ( +
+
+ + + + + {isAdmin &&
+ +
} +
+
+ +
+
+ ); + } + +} diff --git a/app/soapbox/pages/remote_instance_page.js b/app/soapbox/pages/remote_instance_page.js index 218dbcf72..bf2b9f04c 100644 --- a/app/soapbox/pages/remote_instance_page.js +++ b/app/soapbox/pages/remote_instance_page.js @@ -8,6 +8,7 @@ import FeaturesPanel from 'soapbox/features/ui/components/features_panel'; import LinkFooter from 'soapbox/features/ui/components/link_footer'; import { getFeatures } from 'soapbox/utils/features'; import InstanceInfoPanel from 'soapbox/features/ui/components/instance_info_panel'; +import InstanceModerationPanel from 'soapbox/features/ui/components/instance_moderation_panel'; import { federationRestrictionsDisclosed } from 'soapbox/utils/state'; import { isAdmin } from 'soapbox/utils/accounts'; @@ -38,7 +39,8 @@ class RemoteInstancePage extends ImmutablePureComponent {
- {(disclosed || isAdmin) && } + + {(disclosed || isAdmin) && }
diff --git a/app/styles/components/remote-timeline.scss b/app/styles/components/remote-timeline.scss index 76e90b38d..7150afb18 100644 --- a/app/styles/components/remote-timeline.scss +++ b/app/styles/components/remote-timeline.scss @@ -27,3 +27,48 @@ padding-right: 10px; } } + +.pinned-hosts-picker { + margin-left: 10px; + display: inline-flex; + flex-wrap: wrap; + + .pinned-host { + margin-right: 10px; + margin-bottom: 10px; + } + + &:hover { + .pinned-host { + background: var(--background-color); + + &:hover { + background: var(--brand-color--faint); + } + } + } +} + +.pinned-host { + background: var(--background-color); + border-radius: 999px; + transition: 0.2s; + + &.active { + background: var(--brand-color--faint); + + a { + color: #fff; + } + } + + a { + display: block; + color: var(--primary-text-color); + text-decoration: none; + padding: 5px 11px; + max-width: 115px; + overflow: hidden; + text-overflow: ellipsis; + } +}