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;
+ }
+}