From 4aa5ebe59142eedbb1aa9dad88c608dda9ad8d6c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 19 Feb 2017 20:25:54 +0100 Subject: [PATCH] Split public timeline into "public timeline" which is local, and "whole known network" which is what public timeline used to be Only domain blocks with suspend severity will block PuSH subscriptions Silenced accounts should not appear in conversations unless followed --- .../components/actions/compose.jsx | 1 + .../components/actions/timelines.jsx | 64 ++++++++-------- .../components/containers/mastodon.jsx | 2 + .../features/community_timeline/index.jsx | 73 +++++++++++++++++++ .../features/compose/components/drawer.jsx | 2 + .../features/getting_started/index.jsx | 4 +- .../features/public_timeline/index.jsx | 6 +- .../ui/containers/status_list_container.jsx | 4 + .../javascripts/components/locales/en.jsx | 7 +- .../components/reducers/timelines.jsx | 37 ++++++---- app/models/domain_block.rb | 2 +- app/models/status.rb | 2 +- 12 files changed, 148 insertions(+), 56 deletions(-) create mode 100644 app/assets/javascripts/components/features/community_timeline/index.jsx diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 03aae885e0..8d030fd30b 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -85,6 +85,7 @@ export function submitCompose() { dispatch(updateTimeline('home', { ...response.data })); if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { + dispatch(updateTimeline('community', { ...response.data })); dispatch(updateTimeline('public', { ...response.data })); } }).catch(function (error) { diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 1531b89a33..f2680177cd 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -1,4 +1,4 @@ -import api from '../api' +import api, { getLinks } from '../api' import Immutable from 'immutable'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; @@ -14,12 +14,13 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; -export function refreshTimelineSuccess(timeline, statuses, skipLoading) { +export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { return { type: TIMELINE_REFRESH_SUCCESS, timeline, statuses, - skipLoading + skipLoading, + next }; }; @@ -69,25 +70,22 @@ export function refreshTimeline(timeline, id = null) { const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; + const params = getState().getIn(['timelines', timeline, 'params'], {}); + const path = getState().getIn(['timelines', timeline, 'path'])(id); - let params = ''; - let path = timeline; let skipLoading = false; if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) { - params = `?since_id=${newestId}`; - skipLoading = true; - } - - if (id) { - path = `${path}/${id}` + params.since_id = newestId; + skipLoading = true; } dispatch(refreshTimelineRequest(timeline, id, skipLoading)); - api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) { - dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading)); - }).catch(function (error) { + api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null)); + }).catch(error => { dispatch(refreshTimelineFail(timeline, error, skipLoading)); }); }; @@ -102,50 +100,48 @@ export function refreshTimelineFail(timeline, error, skipLoading) { }; }; -export function expandTimeline(timeline, id = null) { +export function expandTimeline(timeline) { return (dispatch, getState) => { - const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last(); - - if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) { - // If timeline is empty, don't try to load older posts since there are none - // Also if already loading + if (getState().getIn(['timelines', timeline, 'isLoading'])) { return; } - dispatch(expandTimelineRequest(timeline, id)); + const next = getState().getIn(['timelines', timeline, 'next']); + const params = getState().getIn(['timelines', timeline, 'params'], {}); - let path = timeline; - - if (id) { - path = `${path}/${id}` + if (next === null) { + return; } - api(getState).get(`/api/v1/timelines/${path}`, { + dispatch(expandTimelineRequest(timeline)); + + api(getState).get(next, { params: { - limit: 10, - max_id: lastId + ...params, + limit: 10 } }).then(response => { - dispatch(expandTimelineSuccess(timeline, response.data)); + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandTimelineFail(timeline, error)); }); }; }; -export function expandTimelineRequest(timeline, id) { +export function expandTimelineRequest(timeline) { return { type: TIMELINE_EXPAND_REQUEST, - timeline, - id + timeline }; }; -export function expandTimelineSuccess(timeline, statuses) { +export function expandTimelineSuccess(timeline, statuses, next) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, - statuses + statuses, + next }; }; diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index ebef5c81bc..3e7abda451 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -21,6 +21,7 @@ import UI from '../features/ui'; import Status from '../features/status'; import GettingStarted from '../features/getting_started'; import PublicTimeline from '../features/public_timeline'; +import CommunityTimeline from '../features/community_timeline'; import AccountTimeline from '../features/account_timeline'; import HomeTimeline from '../features/home_timeline'; import Compose from '../features/compose'; @@ -116,6 +117,7 @@ const Mastodon = React.createClass({ + diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx new file mode 100644 index 0000000000..736c5d58f4 --- /dev/null +++ b/app/assets/javascripts/components/features/community_timeline/index.jsx @@ -0,0 +1,73 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { + refreshTimeline, + updateTimeline, + deleteFromTimelines +} from '../../actions/timelines'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Public' } +}); + +const mapStateToProps = state => ({ + accessToken: state.getIn(['meta', 'access_token']) +}); + +const CommunityTimeline = React.createClass({ + + propTypes: { + dispatch: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired, + accessToken: React.PropTypes.string.isRequired + }, + + mixins: [PureRenderMixin], + + componentDidMount () { + const { dispatch, accessToken } = this.props; + + dispatch(refreshTimeline('community')); + + this.subscription = createStream(accessToken, 'public:local', { + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('community', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + }, + + componentWillUnmount () { + if (typeof this.subscription !== 'undefined') { + this.subscription.close(); + this.subscription = null; + } + }, + + render () { + const { intl } = this.props; + + return ( + + + } /> + + ); + }, + +}); + +export default connect(mapStateToProps)(injectIntl(CommunityTimeline)); diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx index 83f3fa27dd..a8f76f983d 100644 --- a/app/assets/javascripts/components/features/compose/components/drawer.jsx +++ b/app/assets/javascripts/components/features/compose/components/drawer.jsx @@ -4,6 +4,7 @@ import { injectIntl, defineMessages } from 'react-intl'; const messages = defineMessages({ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' }, + community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Community timeline' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } }); @@ -15,6 +16,7 @@ const Drawer = ({ children, withHeader, intl }) => { header = (
+ diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index af86919c13..0f04cab4ca 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -7,7 +7,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; const messages = defineMessages({ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, - public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' }, + public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, + community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Public timeline' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' }, @@ -30,6 +31,7 @@ const GettingStarted = ({ intl, me }) => { return (
+ diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx index 36d68dbbba..d85f49f2cb 100644 --- a/app/assets/javascripts/components/features/public_timeline/index.jsx +++ b/app/assets/javascripts/components/features/public_timeline/index.jsx @@ -7,12 +7,12 @@ import { updateTimeline, deleteFromTimelines } from '../../actions/timelines'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import createStream from '../../stream'; const messages = defineMessages({ - title: { id: 'column.public', defaultMessage: 'Public' } + title: { id: 'column.public', defaultMessage: 'Whole Known Network' } }); const mapStateToProps = state => ({ @@ -63,7 +63,7 @@ const PublicTimeline = React.createClass({ return ( - + } /> ); }, diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index 100989d225..9b7bbf0721 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -3,6 +3,7 @@ import StatusList from '../../../components/status_list'; import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; import Immutable from 'immutable'; import { createSelector } from 'reselect'; +import { debounce } from 'react-decoration'; const getStatusIds = createSelector([ (state, { type }) => state.getIn(['settings', type], Immutable.Map()), @@ -40,15 +41,18 @@ const mapStateToProps = (state, props) => ({ const mapDispatchToProps = (dispatch, { type, id }) => ({ + @debounce(300, true) onScrollToBottom () { dispatch(scrollTopTimeline(type, false)); dispatch(expandTimeline(type, id)); }, + @debounce(300, true) onScrollToTop () { dispatch(scrollTopTimeline(type, true)); }, + @debounce(500) onScroll () { dispatch(scrollTopTimeline(type, false)); } diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index 95962fd738..cf01a59b8c 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -28,8 +28,8 @@ const en = { "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social", "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.", "column.home": "Home", - "column.mentions": "Mentions", - "column.public": "Public", + "column.community": "Public", + "column.public": "Whole Known Network", "column.notifications": "Notifications", "tabs_bar.compose": "Compose", "tabs_bar.home": "Home", @@ -45,7 +45,8 @@ const en = { "compose_form.unlisted": "Do not display in public timeline", "navigation_bar.edit_profile": "Edit profile", "navigation_bar.preferences": "Preferences", - "navigation_bar.public_timeline": "Public timeline", + "navigation_bar.community_timeline": "Public timeline", + "navigation_bar.public_timeline": "Whole Known Network", "navigation_bar.logout": "Logout", "reply_indicator.cancel": "Cancel", "search.placeholder": "Search", diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 6f2d26dcb7..1c71d822d3 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -31,13 +31,8 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ home: Immutable.Map({ - isLoading: false, - loaded: false, - top: true, - items: Immutable.List() - }), - - mentions: Immutable.Map({ + path: () => '/api/v1/timelines/home', + next: null, isLoading: false, loaded: false, top: true, @@ -45,6 +40,18 @@ const initialState = Immutable.Map({ }), public: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, + isLoading: false, + loaded: false, + top: true, + items: Immutable.List() + }), + + community: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, + params: { local: true }, isLoading: false, loaded: false, top: true, @@ -52,6 +59,8 @@ const initialState = Immutable.Map({ }), tag: Immutable.Map({ + path: (id) => `/api/v1/timelines/tag/${id}`, + next: null, isLoading: false, id: null, loaded: false, @@ -81,7 +90,7 @@ const normalizeStatus = (state, status) => { return state; }; -const normalizeTimeline = (state, timeline, statuses, replace = false) => { +const normalizeTimeline = (state, timeline, statuses, next) => { let ids = Immutable.List(); const loaded = state.getIn([timeline, 'loaded']); @@ -92,11 +101,12 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => { state = state.setIn([timeline, 'loaded'], true); state = state.setIn([timeline, 'isLoading'], false); + state = state.setIn([timeline, 'next'], next); return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids)); }; -const appendNormalizedTimeline = (state, timeline, statuses) => { +const appendNormalizedTimeline = (state, timeline, statuses, next) => { let moreIds = Immutable.List(); statuses.forEach((status, i) => { @@ -105,6 +115,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => { }); state = state.setIn([timeline, 'isLoading'], false); + state = state.setIn([timeline, 'next'], next); return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds)); }; @@ -169,7 +180,7 @@ const deleteStatus = (state, id, accountId, references, reblogOf) => { } // Remove references from timelines - ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) { + ['home', 'public', 'community', 'tag'].forEach(function (timeline) { state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); }); @@ -221,7 +232,7 @@ const normalizeContext = (state, id, ancestors, descendants) => { }; const resetTimeline = (state, timeline, id) => { - if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) { + if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) { state = state.update(timeline, map => map .set('id', id) .set('isLoading', true) @@ -243,9 +254,9 @@ export default function timelines(state = initialState, action) { case TIMELINE_EXPAND_FAIL: return state.setIn([action.timeline, 'isLoading'], false); case TIMELINE_REFRESH_SUCCESS: - return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); case TIMELINE_EXPAND_SUCCESS: - return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); case TIMELINE_UPDATE: return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); case TIMELINE_DELETE: diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index b4606da60d..3548ccd692 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -6,6 +6,6 @@ class DomainBlock < ApplicationRecord validates :domain, presence: true, uniqueness: true def self.blocked?(domain) - where(domain: domain).exists? + where(domain: domain, severity: :suspend).exists? end end diff --git a/app/models/status.rb b/app/models/status.rb index 46d92ea331..1b40897f36 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -192,6 +192,6 @@ class Status < ApplicationRecord private def filter_from_context?(status, account) - account&.blocking?(status.account_id) || !status.permitted?(account) + account&.blocking?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account) end end