diff options
12 files changed, 143 insertions, 51 deletions
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 03aae885e..8d030fd30 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 1531b89a3..f2680177c 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 ebef5c81b..3e7abda45 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({ <Route path='getting-started' component={GettingStarted} /> <Route path='timelines/home' component={HomeTimeline} /> <Route path='timelines/public' component={PublicTimeline} /> + <Route path='timelines/community' component={CommunityTimeline} /> <Route path='timelines/tag/:id' component={HashtagTimeline} /> <Route path='notifications' component={Notifications} /> 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 000000000..736c5d58f --- /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 ( + <Column icon='users' heading={intl.formatMessage(messages.title)}> + <ColumnBackButtonSlim /> + <StatusListContainer type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The community timeline is empty. Write something publicly to get the ball rolling!' />} /> + </Column> + ); + }, + +}); + +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 83f3fa27d..a8f76f983 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 = ( <div className='drawer__header'> <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> + <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/community'><i className='fa fa-fw fa-users' /></Link> <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index af86919c1..0f04cab4c 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 ( <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}> <div style={{ position: 'relative' }}> + <ColumnLink icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/community' /> <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx index 36d68dbbb..d85f49f2c 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 ( <Column icon='globe' heading={intl.formatMessage(messages.title)}> <ColumnBackButtonSlim /> - <StatusListContainer type='public' /> + <StatusListContainer type='public' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} /> </Column> ); }, 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 100989d22..9b7bbf072 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 95962fd73..cf01a59b8 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 6f2d26dcb..1c71d822d 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -31,20 +31,27 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ home: Immutable.Map({ + path: () => '/api/v1/timelines/home', + next: null, isLoading: false, loaded: false, top: true, items: Immutable.List() }), - mentions: Immutable.Map({ + public: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, isLoading: false, loaded: false, top: true, items: Immutable.List() }), - public: Immutable.Map({ + 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 b4606da60..3548ccd69 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 46d92ea33..1b40897f3 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 |