diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx index 99d83b609c..bf1ba54fca 100644 --- a/app/assets/javascripts/components/components/status_content.jsx +++ b/app/assets/javascripts/components/components/status_content.jsx @@ -26,7 +26,7 @@ const StatusContent = React.createClass({ } else { link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener'); - link.addEventListener('click', this.onNormalClick.bind(this)); + link.addEventListener('click', this.onNormalClick); } } }, diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index 6c65c303bd..24db6424af 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -18,6 +18,7 @@ import { import Account from '../features/account'; import Status from '../features/status'; import GettingStarted from '../features/getting_started'; +import PublicTimeline from '../features/public_timeline'; import UI from '../features/ui'; const store = configureStore(); @@ -43,14 +44,7 @@ const Mastodon = React.createClass({ } if (typeof App !== 'undefined') { - App.timeline = App.cable.subscriptions.create("TimelineChannel", { - connected () { - - }, - - disconnected () { - - }, + this.subscription = App.cable.subscriptions.create('TimelineChannel', { received (data) { switch(data.type) { @@ -65,16 +59,24 @@ const Mastodon = React.createClass({ return store.dispatch(refreshTimeline('mentions')); } } + }); } }, + componentWillUnmount () { + if (typeof this.subscription !== 'undefined') { + this.subscription.unsubscribe(); + } + }, + render () { return ( + diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx index c9de1a848f..9c77214d12 100644 --- a/app/assets/javascripts/components/features/account/index.jsx +++ b/app/assets/javascripts/components/features/account/index.jsx @@ -27,9 +27,10 @@ import StatusList from '../../components/status_list'; import LoadingIndicator from '../../components/loading_indicator'; import Immutable from 'immutable'; import ActionBar from './components/action_bar'; +import Column from '../ui/components/column'; function selectStatuses(state, accountId) { - return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null); + return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List([])).map(id => selectStatus(state, id)).filterNot(status => status === null); }; const mapStateToProps = (state, props) => ({ @@ -109,15 +110,21 @@ const Account = React.createClass({ const { account, statuses, me } = this.props; if (account === null) { - return ; + return ( + + + + ); } return ( -
-
- - -
+ +
+
+ + +
+
); } diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index d507cb46fd..a4f1ac4874 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -1,12 +1,16 @@ +import Column from '../ui/components/column'; + const GettingStarted = () => { return ( -
-

Getting started

-

Mastodon is still in development and one of the lacking areas at the moment is user discovery.

-

You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.

-

If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.

-

The developer of this project can be followed as Gargron@mastodon.social

-
+ +
+

Getting started

+

Mastodon is still in development and one of the lacking areas at the moment is user discovery.

+

You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.

+

If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.

+

The developer of this project can be followed as Gargron@mastodon.social

+
+
); }; diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx new file mode 100644 index 0000000000..dd31dc1158 --- /dev/null +++ b/app/assets/javascripts/components/features/public_timeline/index.jsx @@ -0,0 +1,103 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import StatusList from '../../components/status_list'; +import Column from '../ui/components/column'; +import Immutable from 'immutable'; +import { selectStatus } from '../../reducers/timelines'; +import { + updateTimeline, + refreshTimeline, + expandTimeline +} from '../../actions/timelines'; +import { deleteStatus } from '../../actions/statuses'; +import { replyCompose } from '../../actions/compose'; +import { + favourite, + reblog, + unreblog, + unfavourite +} from '../../actions/interactions'; + +function selectStatuses(state) { + return state.getIn(['timelines', 'public'], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null); +}; + +const mapStateToProps = (state) => ({ + statuses: selectStatuses(state), + me: state.getIn(['timelines', 'me']) +}); + +const PublicTimeline = React.createClass({ + + propTypes: { + statuses: ImmutablePropTypes.list.isRequired, + me: React.PropTypes.number.isRequired, + dispatch: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + const { dispatch } = this.props; + + dispatch(refreshTimeline('public')); + + if (typeof App !== 'undefined') { + this.subscription = App.cable.subscriptions.create('PublicChannel', { + + received (data) { + dispatch(updateTimeline('public', JSON.parse(data.message))); + } + + }); + } + }, + + componentWillUnmount () { + if (typeof this.subscription !== 'undefined') { + this.subscription.unsubscribe(); + } + }, + + handleReply (status) { + this.props.dispatch(replyCompose(status)); + }, + + handleReblog (status) { + if (status.get('reblogged')) { + this.props.dispatch(unreblog(status)); + } else { + this.props.dispatch(reblog(status)); + } + }, + + handleFavourite (status) { + if (status.get('favourited')) { + this.props.dispatch(unfavourite(status)); + } else { + this.props.dispatch(favourite(status)); + } + }, + + handleDelete (status) { + this.props.dispatch(deleteStatus(status.get('id'))); + }, + + handleScrollToBottom () { + this.props.dispatch(expandTimeline('public')); + }, + + render () { + const { statuses, me } = this.props; + + return ( + + + + ); + }, + +}); + +export default connect(mapStateToProps)(PublicTimeline); diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index c294ac1d68..b282956b14 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -7,6 +7,7 @@ import EmbeddedStatus from '../../components/status'; import LoadingIndicator from '../../components/loading_indicator'; import DetailedStatus from './components/detailed_status'; import ActionBar from './components/action_bar'; +import Column from '../ui/components/column'; import { favourite, reblog } from '../../actions/interactions'; import { replyCompose } from '../../actions/compose'; import { selectStatus } from '../../reducers/timelines'; @@ -64,20 +65,26 @@ const Status = React.createClass({ const { status, ancestors, descendants, me } = this.props; if (status === null) { - return ; + return ( + + + + ); } const account = status.get('account'); return ( -
-
{this.renderChildren(ancestors)}
+ +
+
{this.renderChildren(ancestors)}
- - + + -
{this.renderChildren(descendants)}
-
+
{this.renderChildren(descendants)}
+
+ ); } diff --git a/app/assets/javascripts/components/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx index 499e5f4a58..bb9331267e 100644 --- a/app/assets/javascripts/components/features/ui/components/column.jsx +++ b/app/assets/javascripts/components/features/ui/components/column.jsx @@ -29,7 +29,6 @@ const scrollTop = (node) => { }; }; - const Column = React.createClass({ propTypes: { @@ -50,10 +49,6 @@ const Column = React.createClass({ } }, - handleScroll () { - // todo - }, - render () { let header = ''; @@ -61,10 +56,10 @@ const Column = React.createClass({ header = ; } - const style = { width: '350px', flex: '0 0 auto', background: '#282c37', margin: '10px', marginRight: '0', marginBottom: '0', display: 'flex', flexDirection: 'column' }; + const style = { width: '330px', flex: '0 0 auto', background: '#282c37', margin: '10px', marginRight: '0', marginBottom: '0', display: 'flex', flexDirection: 'column' }; return ( -
+
{header} {this.props.children}
diff --git a/app/assets/javascripts/components/features/ui/components/columns_area.jsx b/app/assets/javascripts/components/features/ui/components/columns_area.jsx index fa4c1a9c93..94433539bf 100644 --- a/app/assets/javascripts/components/features/ui/components/columns_area.jsx +++ b/app/assets/javascripts/components/features/ui/components/columns_area.jsx @@ -6,7 +6,7 @@ const ColumnsArea = React.createClass({ render () { return ( -
+
{this.props.children}
); diff --git a/app/assets/javascripts/components/features/ui/components/navigation_bar.jsx b/app/assets/javascripts/components/features/ui/components/navigation_bar.jsx index 9d9481d3e2..a168525411 100644 --- a/app/assets/javascripts/components/features/ui/components/navigation_bar.jsx +++ b/app/assets/javascripts/components/features/ui/components/navigation_bar.jsx @@ -19,7 +19,7 @@ const NavigationBar = React.createClass({
{this.props.account.get('acct')} - Settings · Logout + Settings · Public timeline · Logout
); diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx index 323729dd64..0bc235b534 100644 --- a/app/assets/javascripts/components/features/ui/index.jsx +++ b/app/assets/javascripts/components/features/ui/index.jsx @@ -40,9 +40,7 @@ const UI = React.createClass({ - - {this.props.children} - + {this.props.children} diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 3926831509..0b02ac1817 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -30,6 +30,7 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ home: Immutable.List([]), mentions: Immutable.List([]), + public: Immutable.List([]), statuses: Immutable.Map(), accounts: Immutable.Map(), accounts_timelines: Immutable.Map(), @@ -110,7 +111,7 @@ function normalizeTimeline(state, timeline, statuses) { }; function appendNormalizedTimeline(state, timeline, statuses) { - let moreIds = Immutable.List(); + let moreIds = Immutable.List([]); statuses.forEach((status, i) => { state = normalizeStatus(state, status); @@ -121,29 +122,33 @@ function appendNormalizedTimeline(state, timeline, statuses) { }; function normalizeAccountTimeline(state, accountId, statuses) { + state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => { + return (list.size > 0) ? list.clear() : list; + }); + statuses.forEach((status, i) => { state = normalizeStatus(state, status); - state = state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.set(i, status.get('id'))); + state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.set(i, status.get('id'))); }); return state; }; function appendNormalizedAccountTimeline(state, accountId, statuses) { - let moreIds = Immutable.List(); + let moreIds = Immutable.List([]); statuses.forEach((status, i) => { state = normalizeStatus(state, status); moreIds = moreIds.set(i, status.get('id')); }); - return state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.push(...moreIds)); + return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds)); }; function updateTimeline(state, timeline, status) { state = normalizeStatus(state, status); state = state.update(timeline, list => list.unshift(status.get('id'))); - state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List(), list => list.unshift(status.get('id'))); + state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List([]), list => (list.includes(status.get('id')) ? list : list.unshift(status.get('id')))); return state; }; @@ -161,7 +166,7 @@ function deleteStatus(state, id) { }); // Remove references from account timelines - state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id)); + state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List([]), list => list.filterNot(item => item === id)); // Remove reblogs of deleted status const references = state.get('statuses').filter(item => item.get('reblog') === id); diff --git a/app/channels/public_channel.rb b/app/channels/public_channel.rb new file mode 100644 index 0000000000..870b5cc2e3 --- /dev/null +++ b/app/channels/public_channel.rb @@ -0,0 +1,19 @@ +# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading. +class PublicChannel < ApplicationCable::Channel + def subscribed + stream_from 'timeline:public', -> (encoded_message) do + message = ActiveSupport::JSON.decode(encoded_message) + + status = Status.find_by(id: message['id']) + next if status.nil? + + message['message'] = FeedManager.instance.inline_render(current_user.account, status) + + transmit message + end + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index ec1056a42e..952ed641d9 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -46,9 +46,16 @@ class Api::V1::StatusesController < ApiController def home @statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a + render action: :index end def mentions @statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a + render action: :index + end + + def public + @statuses = Status.with_includes.with_counters.order('id desc').paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a + render action: :index end end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index 61a4b4dd83..6b51517775 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -6,8 +6,8 @@ module HomeHelper account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json), timelines: { - home: render(file: 'api/v1/statuses/home', locals: { statuses: @home }, formats: :json), - mentions: render(file: 'api/v1/statuses/mentions', locals: { statuses: @mentions }, formats: :json) + home: render(file: 'api/v1/statuses/index', locals: { statuses: @home }, formats: :json), + mentions: render(file: 'api/v1/statuses/index', locals: { statuses: @mentions }, formats: :json) } } end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 58d6a005c6..46a1051246 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -33,22 +33,6 @@ class FeedManager redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}") end - private - - def redis - $redis - end - - # Filter status out of the home feed if it is a reply to someone the user doesn't follow - def filter_from_home?(status, receiver) - replied_to_user = status.reply? ? status.thread.account : nil - (status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user))) - end - - def filter_from_mentions?(status, receiver) - receiver.blocking?(status.account) || (status.reblog? && receiver.blocking?(status.reblog.account)) - end - def inline_render(target_account, status) rabl_scope = Class.new do include RoutingHelper @@ -58,7 +42,7 @@ class FeedManager end def current_user - @account.user + @account.try(:user) end def current_account @@ -68,4 +52,20 @@ class FeedManager Rabl::Renderer.new('api/v1/statuses/show', status, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render end + + private + + def redis + $redis + end + + # Filter status out of the home feed if it is a reply to someone the user doesn't follow + def filter_from_home?(status, receiver) + replied_to_user = status.reply? ? status.thread.account : nil + (status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user))) || (status.reblog? && receiver.blocking?(status.reblog.account)) + end + + def filter_from_mentions?(status, receiver) + receiver.blocking?(status.account) + end end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 29b093ef88..312994db54 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -5,6 +5,7 @@ class FanOutOnWriteService < BaseService deliver_to_self(status) if status.account.local? deliver_to_followers(status) deliver_to_mentioned(status) + deliver_to_public(status) end private @@ -27,4 +28,8 @@ class FanOutOnWriteService < BaseService FeedManager.instance.push(:mentions, mentioned_account, status) end end + + def deliver_to_public(status) + FeedManager.instance.broadcast(:public, id: status.id) + end end diff --git a/app/views/api/v1/statuses/home.rabl b/app/views/api/v1/statuses/index.rabl similarity index 100% rename from app/views/api/v1/statuses/home.rabl rename to app/views/api/v1/statuses/index.rabl diff --git a/app/views/api/v1/statuses/mentions.rabl b/app/views/api/v1/statuses/mentions.rabl deleted file mode 100644 index 0a0ed13c5b..0000000000 --- a/app/views/api/v1/statuses/mentions.rabl +++ /dev/null @@ -1,2 +0,0 @@ -collection @statuses -extends('api/v1/statuses/show') diff --git a/app/views/api/v1/statuses/show.rabl b/app/views/api/v1/statuses/show.rabl index 3595bafb44..20cb65e298 100644 --- a/app/views/api/v1/statuses/show.rabl +++ b/app/views/api/v1/statuses/show.rabl @@ -6,8 +6,8 @@ node(:content) { |status| Formatter.instance.format(status) } node(:url) { |status| TagManager.instance.url_for(status) } node(:reblogs_count) { |status| status.reblogs_count } node(:favourites_count) { |status| status.favourites_count } -node(:favourited) { |status| current_account.favourited?(status) } -node(:reblogged) { |status| current_account.reblogged?(status) } +node(:favourited, if: proc { !current_account.nil? }) { |status| current_account.favourited?(status) } +node(:reblogged, if: proc { !current_account.nil? }) { |status| current_account.reblogged?(status) } child :reblog => :reblog do extends('api/v1/statuses/show') diff --git a/config/routes.rb b/config/routes.rb index 8f9e5fe14e..94507d3248 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,6 +48,7 @@ Rails.application.routes.draw do collection do get :home get :mentions + get :public end member do diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb index 7af54299a4..66060c57e9 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/statuses_controller_spec.rb @@ -47,6 +47,13 @@ RSpec.describe Api::V1::StatusesController, type: :controller do end end + describe 'GET #public' do + it 'returns http success' do + get :public + expect(response).to have_http_status(:success) + end + end + describe 'POST #create' do before do post :create, params: { status: 'Hello world' }