diff --git a/.gitignore b/.gitignore index a289a4983..340657ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ public/system public/assets .env .env.* +node_modules/ diff --git a/Gemfile b/Gemfile index 6b6ded4d8..c4f4480f1 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,9 @@ gem 'rack-attack' gem 'sidekiq' gem 'sinatra', require: nil, github: 'sinatra' +gem 'react-rails' +gem 'browserify-rails' + group :development, :test do gem 'rspec-rails' gem 'pry-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 19b9b7fb6..919a40a56 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -53,6 +53,10 @@ GEM addressable (2.4.0) arel (7.1.1) ast (2.3.0) + babel-source (5.8.35) + babel-transpiler (0.7.0) + babel-source (>= 4.0, < 6) + execjs (~> 2.0) bcrypt (3.1.11) better_errors (2.1.1) coderay (>= 1.0.0) @@ -60,6 +64,9 @@ GEM rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) + browserify-rails (3.1.0) + railties (>= 4.0.0, < 5.1) + sprockets (>= 3.5.2) builder (3.2.2) bullet (5.3.0) activesupport (>= 3.0.0) @@ -245,6 +252,13 @@ GEM rake (11.2.2) rdoc (4.2.2) json (~> 1.4) + react-rails (1.8.2) + babel-transpiler (>= 0.7.0) + coffee-script-source (~> 1.8) + connection_pool + execjs + railties (>= 3.2) + tilt redis (3.3.1) ref (2.0.0) responders (2.3.0) @@ -348,6 +362,7 @@ DEPENDENCIES addressable better_errors binding_of_caller + browserify-rails bullet coffee-rails (~> 4.1.0) devise @@ -380,6 +395,7 @@ DEPENDENCIES rails (= 5.0.0.1) rails_12factor rails_autolink + react-rails redis (~> 3.2) rspec-rails rspec-sidekiq diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 646c5aba4..b9d77b07f 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -12,4 +12,6 @@ // //= require jquery //= require jquery_ujs -//= require_tree . +//= require components +//= require cable +//= require mastodon-logo diff --git a/app/assets/javascripts/channels/timeline.js b/app/assets/javascripts/channels/timeline.js deleted file mode 100644 index ca7c50d12..000000000 --- a/app/assets/javascripts/channels/timeline.js +++ /dev/null @@ -1,13 +0,0 @@ -App.timeline = App.cable.subscriptions.create("TimelineChannel", { - connected: function() { - console.log('Connected'); - }, - - disconnected: function() { - console.log('Disconnected'); - }, - - received: function(data) { - console.log(JSON.parse(data.message)); - } -}); diff --git a/app/assets/javascripts/components.js b/app/assets/javascripts/components.js new file mode 100644 index 000000000..d4d9b97e4 --- /dev/null +++ b/app/assets/javascripts/components.js @@ -0,0 +1,9 @@ +//= require_self +//= require react_ujs + +window.React = require('react'); +window.ReactDOM = require('react-dom'); + +//= require_tree ./components + +window.Root = require('./components/containers/root'); diff --git a/app/assets/javascripts/components/.gitkeep b/app/assets/javascripts/components/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx new file mode 100644 index 000000000..21821b8ba --- /dev/null +++ b/app/assets/javascripts/components/actions/statuses.jsx @@ -0,0 +1,18 @@ +export const SET_TIMELINE = 'SET_TIMELINE'; +export const ADD_STATUS = 'ADD_STATUS'; + +export function setTimeline(timeline, statuses) { + return { + type: SET_TIMELINE, + timeline: timeline, + statuses: statuses + }; +} + +export function addStatus(timeline, status) { + return { + type: ADD_STATUS, + timeline: timeline, + status: status + }; +} diff --git a/app/assets/javascripts/components/components/column.jsx b/app/assets/javascripts/components/components/column.jsx new file mode 100644 index 000000000..c585b6b0b --- /dev/null +++ b/app/assets/javascripts/components/components/column.jsx @@ -0,0 +1,19 @@ +import StatusListContainer from '../containers/status_list_container'; +import ColumnHeader from './column_header'; + +const Column = React.createClass({ + propTypes: { + type: React.PropTypes.string + }, + + render: function() { + return ( +
+ + +
+ ); + } +}); + +export default Column; diff --git a/app/assets/javascripts/components/components/column_header.jsx b/app/assets/javascripts/components/components/column_header.jsx new file mode 100644 index 000000000..e2f7d7c1c --- /dev/null +++ b/app/assets/javascripts/components/components/column_header.jsx @@ -0,0 +1,15 @@ +const ColumnHeader = React.createClass({ + propTypes: { + type: React.PropTypes.string + }, + + render: function() { + return ( +
+ {this.props.type} +
+ ); + } +}); + +export default ColumnHeader; diff --git a/app/assets/javascripts/components/components/columns_area.jsx b/app/assets/javascripts/components/components/columns_area.jsx new file mode 100644 index 000000000..1c46f722d --- /dev/null +++ b/app/assets/javascripts/components/components/columns_area.jsx @@ -0,0 +1,15 @@ +import Column from './column'; + +const ColumnsArea = React.createClass({ + + render: function() { + return ( +
+ + +
+ ); + } +}); + +export default ColumnsArea; diff --git a/app/assets/javascripts/components/components/frontend.jsx b/app/assets/javascripts/components/components/frontend.jsx new file mode 100644 index 000000000..6f9c46fa9 --- /dev/null +++ b/app/assets/javascripts/components/components/frontend.jsx @@ -0,0 +1,16 @@ +import NavBar from './nav_bar'; +import ColumnsArea from './columns_area'; + +const Frontend = React.createClass({ + + render: function() { + return ( +
+ + +
+ ); + } +}); + +export default Frontend; diff --git a/app/assets/javascripts/components/components/nav_bar.jsx b/app/assets/javascripts/components/components/nav_bar.jsx new file mode 100644 index 000000000..1ece3cc34 --- /dev/null +++ b/app/assets/javascripts/components/components/nav_bar.jsx @@ -0,0 +1,8 @@ +const NavBar = React.createClass({ + + render: function() { + return
; + } +}); + +export default NavBar; diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx new file mode 100644 index 000000000..9bbb02077 --- /dev/null +++ b/app/assets/javascripts/components/components/status.jsx @@ -0,0 +1,19 @@ +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const Status = React.createClass({ + propTypes: { + status: ImmutablePropTypes.map.isRequired + }, + + render: function() { + console.log(this.props.status.toJS()); + + return ( +
+ {this.props.status.getIn(['account', 'username'])}: {this.props.status.get('content')} +
+ ); + } +}); + +export default Status; diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx new file mode 100644 index 000000000..c986c773b --- /dev/null +++ b/app/assets/javascripts/components/components/status_list.jsx @@ -0,0 +1,22 @@ +import Status from './status'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const StatusList = React.createClass({ + propTypes: { + statuses: ImmutablePropTypes.list.isRequired + }, + + render: function() { + return ( +
+
+ {this.props.statuses.map((status) => { + return ; + })} +
+
+ ); + } +}); + +export default StatusList; diff --git a/app/assets/javascripts/components/containers/root.jsx b/app/assets/javascripts/components/containers/root.jsx new file mode 100644 index 000000000..7da984d89 --- /dev/null +++ b/app/assets/javascripts/components/containers/root.jsx @@ -0,0 +1,40 @@ +import { Provider } from 'react-redux'; +import configureStore from '../store/configureStore'; +import Frontend from '../components/frontend'; +import { setTimeline, addStatus } from '../actions/statuses'; + +const store = configureStore(); + +const Root = React.createClass({ + + componentWillMount() { + for (var timelineType in this.props.timelines) { + if (this.props.timelines.hasOwnProperty(timelineType)) { + store.dispatch(setTimeline(timelineType, JSON.parse(this.props.timelines[timelineType]))); + } + } + + if (typeof App !== 'undefined') { + App.timeline = App.cable.subscriptions.create("TimelineChannel", { + connected: function() {}, + + disconnected: function() {}, + + received: function(data) { + return store.dispatch(addStatus(data.timeline, JSON.parse(data.message))); + } + }); + } + }, + + render() { + return ( + + + + ); + } + +}); + +export default Root; diff --git a/app/assets/javascripts/components/containers/status_list_container.jsx b/app/assets/javascripts/components/containers/status_list_container.jsx new file mode 100644 index 000000000..c2e55db66 --- /dev/null +++ b/app/assets/javascripts/components/containers/status_list_container.jsx @@ -0,0 +1,10 @@ +import { connect } from 'react-redux'; +import StatusList from '../components/status_list'; + +const mapStateToProps = function (state, props) { + return { + statuses: state.getIn(['statuses', props.type]) + }; +}; + +export default connect(mapStateToProps)(StatusList); diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx new file mode 100644 index 000000000..c7e858f38 --- /dev/null +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -0,0 +1,6 @@ +import { combineReducers } from 'redux-immutable'; +import statuses from './statuses'; + +export default combineReducers({ + statuses +}); diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx new file mode 100644 index 000000000..d69d66328 --- /dev/null +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -0,0 +1,17 @@ +import { SET_TIMELINE, ADD_STATUS } from '../actions/statuses'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map(); + +export default function statuses(state = initialState, action) { + switch(action.type) { + case SET_TIMELINE: + return state.set(action.timeline, Immutable.fromJS(action.statuses)); + case ADD_STATUS: + return state.update(action.timeline, function (list) { + list.unshift(Immutable.fromJS(action.status)); + }); + default: + return state; + } +} diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx new file mode 100644 index 000000000..bb5d664d0 --- /dev/null +++ b/app/assets/javascripts/components/store/configureStore.jsx @@ -0,0 +1,6 @@ +import { createStore } from 'redux'; +import appReducer from '../reducers'; + +export default function configureStore(initialState) { + return createStore(appReducer, initialState); +} diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 1f35053a1..668b6f90d 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -67,6 +67,23 @@ body { font-weight: 400; color: #fff; padding-bottom: 140px; + text-rendering: optimizelegibility; + font-feature-settings: "kern"; + + &.app-body { + position: fixed; + width: 100%; + height: 100%; + padding: 0; + } +} + +.app-holder { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; } .container { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c190abdf2..399faa21e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,6 +12,8 @@ class ApplicationController < ActionController::Base end end + helper_method :current_account + protected def current_account diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 9b0b36a86..57973ba49 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,9 +1,9 @@ class HomeController < ApplicationController - layout 'dashboard' - before_action :authenticate_user! def index - @timeline = Feed.new(:home, current_user.account).get(10, params[:max_id]) + @body_classes = 'app-body' + @home = Feed.new(:home, current_user.account).get(20) + @mentions = Feed.new(:mentions, current_user.account).get(20) end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index cb4688292..f6ba958fb 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,6 +1,4 @@ class SettingsController < ApplicationController - layout 'dashboard' - before_action :authenticate_user! before_action :set_account diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 99f3e5079..d6779e0a7 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -1,6 +1,4 @@ class StatusesController < ApplicationController - layout 'dashboard' - before_action :authenticate_user! def create diff --git a/app/models/feed.rb b/app/models/feed.rb index 1d6c2cfbf..e7574956e 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -4,7 +4,7 @@ class Feed @account = account end - def get(limit, max_id) + def get(limit, max_id = nil) max_id = '+inf' if max_id.nil? unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", '-inf', limit: [0, limit]) status_map = Hash.new diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index dc030736b..6e96fa75b 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -31,7 +31,7 @@ class FanOutOnWriteService < BaseService def push(type, receiver, status) redis.zadd(FeedManager.key(type, receiver.id), status.id, status.id) trim(type, receiver) - ActionCable.server.broadcast("timeline:#{receiver.id}", message: inline_render(receiver, status)) + ActionCable.server.broadcast("timeline:#{receiver.id}", timeline: type, message: inline_render(receiver, status)) end def trim(type, receiver) diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index a663bf37b..941397384 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,10 +1 @@ -= simple_form_for Status.new, url: statuses_path, method: :post do |f| - = f.input :text, required: true, autofocus: true, label: false, placeholder: 'What are you up to?' - - .form-actions - = f.button :submit, 'Post update' - -- content_for :raw_content do - .activity-stream.activity-stream-embedded - - @timeline.each do |status| - = render partial: 'stream_entries/status', locals: { status: status } += react_component 'Root', { timelines: { home: render(file: 'api/statuses/home', locals: { statuses: @home }, formats: :json), mentions: render(file: 'api/statuses/mentions', locals: { statuses: @mentions }, formats: :json) }}, class: 'app-holder', prerender: false diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index afd29b04a..1746d9964 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -9,5 +9,5 @@ = javascript_include_tag 'application' = csrf_meta_tags = yield :header_tags - %body + %body{ class: @body_classes } = content_for?(:content) ? yield(:content) : yield diff --git a/app/views/layouts/dashboard.html.haml b/app/views/layouts/dashboard.html.haml deleted file mode 100644 index 7336cdcc4..000000000 --- a/app/views/layouts/dashboard.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -- content_for :content do - .dashboard-wrapper - .dashboard__sidebar - .dashboard__top-bar.alternate -   - .dashboard__current-user - = link_to account_path(current_user.account) do - = image_tag current_user.account.avatar.url(:medium), class: 'dashboard__current-user__avatar' - %strong.dashboard__current-user__display-name= display_name(current_user.account) - %span.dashboard__current-user__username= "@#{current_user.account.username}" - %ul - %li{ class: active_nav_class(root_path) } - = link_to root_path do - = fa_icon 'home' - Home - %li{ class: active_nav_class(oauth_authorized_applications_path) } - = link_to oauth_authorized_applications_path do - = fa_icon 'shield' - Authorized apps - %li{ class: active_nav_class(settings_path) } - = link_to settings_path do - = fa_icon 'user' - Edit profile - - .dashboard__content - .dashboard__top-bar - = content_for?(:page_title) ? yield(:page_title) : 'Mastodon' - %ul - %li= link_to fa_icon('gear'), edit_registration_path(current_user), title: 'Change password' - %li= link_to fa_icon('sign-out'), destroy_user_session_path, method: :delete, title: 'Sign out' - - .dashboard__content__content= yield - - = yield(:raw_content) - - .footer - .domain= Rails.configuration.x.local_domain - -= render template: "layouts/application" diff --git a/config/application.rb b/config/application.rb index c3ea5851a..81205de32 100644 --- a/config/application.rb +++ b/config/application.rb @@ -28,12 +28,14 @@ module Mastodon config.active_job.queue_adapter = :sidekiq config.to_prepare do - Doorkeeper::ApplicationsController.layout 'dashboard' - Doorkeeper::AuthorizedApplicationsController.layout 'dashboard' + # Doorkeeper::ApplicationsController.layout 'dashboard' + # Doorkeeper::AuthorizedApplicationsController.layout 'dashboard' Doorkeeper::AuthorizationsController.layout 'auth' end config.middleware.use Rack::Attack config.middleware.use Rack::Deflater + + config.browserify_rails.commandline_options = "--transform [ babelify --presets [ es2015 react ] ] --extension=\".jsx\"" end end diff --git a/config/environments/development.rb b/config/environments/development.rb index ba0af1f57..c51d98543 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -63,6 +63,8 @@ Rails.application.configure do Bullet.bullet_logger = true Bullet.rails_logger = true end + + config.react.variant = :development end require 'sidekiq/testing' diff --git a/config/environments/production.rb b/config/environments/production.rb index 09b77654f..0a56d4fe0 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -80,4 +80,6 @@ Rails.application.configure do } config.action_mailer.delivery_method = :smtp + + config.react.variant = :production end diff --git a/package.json b/package.json new file mode 100644 index 000000000..7ba01ecfd --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "mastodon", + "devDependencies": { + "babel-preset-es2015": "^6.13.2", + "babel-preset-react": "^6.11.1", + "babelify": "^7.3.0", + "browserify": "^13.1.0", + "browserify-incremental": "^3.1.1", + "react": "^15.3.0", + "react-dom": "^15.3.0", + "redux-devtools": "^3.3.1" + }, + "dependencies": { + "immutable": "^3.8.1", + "react-immutable-proptypes": "^2.1.0", + "react-redux": "^4.4.5", + "redux": "^3.5.2", + "redux-immutable": "^3.0.8" + } +}