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 03aae885e05..8d030fd30b5 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 1531b89a33d..f2680177cd3 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 ebef5c81bc3..3e7abda451f 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 00000000000..736c5d58f4b --- /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 83f3fa27dd4..a8f76f983d5 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 af86919c13d..0f04cab4ca5 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 36d68dbbba1..d85f49f2cb5 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 100989d2259..9b7bbf0721a 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 95962fd7381..cf01a59b8ca 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 6f2d26dcb79..1c71d822d34 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 b4606da60dc..3548ccd6920 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 46d92ea3318..1b40897f36c 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