@@ -20,3 +20,4 @@ public/system | |||||
public/assets | public/assets | ||||
.env | .env | ||||
.env.* | .env.* | ||||
node_modules/ |
@@ -38,6 +38,9 @@ gem 'rack-attack' | |||||
gem 'sidekiq' | gem 'sidekiq' | ||||
gem 'sinatra', require: nil, github: 'sinatra' | gem 'sinatra', require: nil, github: 'sinatra' | ||||
gem 'react-rails' | |||||
gem 'browserify-rails' | |||||
group :development, :test do | group :development, :test do | ||||
gem 'rspec-rails' | gem 'rspec-rails' | ||||
gem 'pry-rails' | gem 'pry-rails' | ||||
@@ -53,6 +53,10 @@ GEM | |||||
addressable (2.4.0) | addressable (2.4.0) | ||||
arel (7.1.1) | arel (7.1.1) | ||||
ast (2.3.0) | 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) | bcrypt (3.1.11) | ||||
better_errors (2.1.1) | better_errors (2.1.1) | ||||
coderay (>= 1.0.0) | coderay (>= 1.0.0) | ||||
@@ -60,6 +64,9 @@ GEM | |||||
rack (>= 0.9.0) | rack (>= 0.9.0) | ||||
binding_of_caller (0.7.2) | binding_of_caller (0.7.2) | ||||
debug_inspector (>= 0.0.1) | debug_inspector (>= 0.0.1) | ||||
browserify-rails (3.1.0) | |||||
railties (>= 4.0.0, < 5.1) | |||||
sprockets (>= 3.5.2) | |||||
builder (3.2.2) | builder (3.2.2) | ||||
bullet (5.3.0) | bullet (5.3.0) | ||||
activesupport (>= 3.0.0) | activesupport (>= 3.0.0) | ||||
@@ -245,6 +252,13 @@ GEM | |||||
rake (11.2.2) | rake (11.2.2) | ||||
rdoc (4.2.2) | rdoc (4.2.2) | ||||
json (~> 1.4) | 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) | redis (3.3.1) | ||||
ref (2.0.0) | ref (2.0.0) | ||||
responders (2.3.0) | responders (2.3.0) | ||||
@@ -348,6 +362,7 @@ DEPENDENCIES | |||||
addressable | addressable | ||||
better_errors | better_errors | ||||
binding_of_caller | binding_of_caller | ||||
browserify-rails | |||||
bullet | bullet | ||||
coffee-rails (~> 4.1.0) | coffee-rails (~> 4.1.0) | ||||
devise | devise | ||||
@@ -380,6 +395,7 @@ DEPENDENCIES | |||||
rails (= 5.0.0.1) | rails (= 5.0.0.1) | ||||
rails_12factor | rails_12factor | ||||
rails_autolink | rails_autolink | ||||
react-rails | |||||
redis (~> 3.2) | redis (~> 3.2) | ||||
rspec-rails | rspec-rails | ||||
rspec-sidekiq | rspec-sidekiq | ||||
@@ -12,4 +12,6 @@ | |||||
// | // | ||||
//= require jquery | //= require jquery | ||||
//= require jquery_ujs | //= require jquery_ujs | ||||
//= require_tree . | |||||
//= require components | |||||
//= require cable | |||||
//= require mastodon-logo |
@@ -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)); | |||||
} | |||||
}); |
@@ -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'); |
@@ -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 | |||||
}; | |||||
} |
@@ -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 ( | |||||
<div style={{ width: '350px', flex: '0 0 auto', background: '#282c37', margin: '10px', marginRight: '0', display: 'flex', flexDirection: 'column' }}> | |||||
<ColumnHeader type={this.props.type} /> | |||||
<StatusListContainer type={this.props.type} /> | |||||
</div> | |||||
); | |||||
} | |||||
}); | |||||
export default Column; |
@@ -0,0 +1,15 @@ | |||||
const ColumnHeader = React.createClass({ | |||||
propTypes: { | |||||
type: React.PropTypes.string | |||||
}, | |||||
render: function() { | |||||
return ( | |||||
<div style={{ padding: '15px', fontSize: '16px', background: '#2f3441', flex: '0 0 auto' }}> | |||||
{this.props.type} | |||||
</div> | |||||
); | |||||
} | |||||
}); | |||||
export default ColumnHeader; |
@@ -0,0 +1,15 @@ | |||||
import Column from './column'; | |||||
const ColumnsArea = React.createClass({ | |||||
render: function() { | |||||
return ( | |||||
<div style={{ display: 'flex', flexDirection: 'row', flex: '1' }}> | |||||
<Column type='home' /> | |||||
<Column type='mentions' /> | |||||
</div> | |||||
); | |||||
} | |||||
}); | |||||
export default ColumnsArea; |
@@ -0,0 +1,16 @@ | |||||
import NavBar from './nav_bar'; | |||||
import ColumnsArea from './columns_area'; | |||||
const Frontend = React.createClass({ | |||||
render: function() { | |||||
return ( | |||||
<div style={{ flex: '0 0 auto', display: 'flex', width: '100%', height: '100%', background: '#1a1c23' }}> | |||||
<NavBar /> | |||||
<ColumnsArea /> | |||||
</div> | |||||
); | |||||
} | |||||
}); | |||||
export default Frontend; |
@@ -0,0 +1,8 @@ | |||||
const NavBar = React.createClass({ | |||||
render: function() { | |||||
return <div style={{ background: '#2f3441', width: '60px', margin: '10px', marginRight: '0' }} />; | |||||
} | |||||
}); | |||||
export default NavBar; |
@@ -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 ( | |||||
<div style={{ height: '100px' }}> | |||||
{this.props.status.getIn(['account', 'username'])}: {this.props.status.get('content')} | |||||
</div> | |||||
); | |||||
} | |||||
}); | |||||
export default Status; |
@@ -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 ( | |||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }}> | |||||
<div> | |||||
{this.props.statuses.map((status) => { | |||||
return <Status key={status.get('id')} status={status} />; | |||||
})} | |||||
</div> | |||||
</div> | |||||
); | |||||
} | |||||
}); | |||||
export default StatusList; |
@@ -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 ( | |||||
<Provider store={store}> | |||||
<Frontend /> | |||||
</Provider> | |||||
); | |||||
} | |||||
}); | |||||
export default Root; |
@@ -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); |
@@ -0,0 +1,6 @@ | |||||
import { combineReducers } from 'redux-immutable'; | |||||
import statuses from './statuses'; | |||||
export default combineReducers({ | |||||
statuses | |||||
}); |
@@ -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; | |||||
} | |||||
} |
@@ -0,0 +1,6 @@ | |||||
import { createStore } from 'redux'; | |||||
import appReducer from '../reducers'; | |||||
export default function configureStore(initialState) { | |||||
return createStore(appReducer, initialState); | |||||
} |
@@ -67,6 +67,23 @@ body { | |||||
font-weight: 400; | font-weight: 400; | ||||
color: #fff; | color: #fff; | ||||
padding-bottom: 140px; | 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 { | .container { | ||||
@@ -12,6 +12,8 @@ class ApplicationController < ActionController::Base | |||||
end | end | ||||
end | end | ||||
helper_method :current_account | |||||
protected | protected | ||||
def current_account | def current_account | ||||
@@ -1,9 +1,9 @@ | |||||
class HomeController < ApplicationController | class HomeController < ApplicationController | ||||
layout 'dashboard' | |||||
before_action :authenticate_user! | before_action :authenticate_user! | ||||
def index | 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 | ||||
end | end |
@@ -1,6 +1,4 @@ | |||||
class SettingsController < ApplicationController | class SettingsController < ApplicationController | ||||
layout 'dashboard' | |||||
before_action :authenticate_user! | before_action :authenticate_user! | ||||
before_action :set_account | before_action :set_account | ||||
@@ -1,6 +1,4 @@ | |||||
class StatusesController < ApplicationController | class StatusesController < ApplicationController | ||||
layout 'dashboard' | |||||
before_action :authenticate_user! | before_action :authenticate_user! | ||||
def create | def create | ||||
@@ -4,7 +4,7 @@ class Feed | |||||
@account = account | @account = account | ||||
end | end | ||||
def get(limit, max_id) | |||||
def get(limit, max_id = nil) | |||||
max_id = '+inf' if max_id.nil? | max_id = '+inf' if max_id.nil? | ||||
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", '-inf', limit: [0, limit]) | unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", '-inf', limit: [0, limit]) | ||||
status_map = Hash.new | status_map = Hash.new | ||||
@@ -31,7 +31,7 @@ class FanOutOnWriteService < BaseService | |||||
def push(type, receiver, status) | def push(type, receiver, status) | ||||
redis.zadd(FeedManager.key(type, receiver.id), status.id, status.id) | redis.zadd(FeedManager.key(type, receiver.id), status.id, status.id) | ||||
trim(type, receiver) | 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 | end | ||||
def trim(type, receiver) | def trim(type, receiver) | ||||
@@ -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 |
@@ -9,5 +9,5 @@ | |||||
= javascript_include_tag 'application' | = javascript_include_tag 'application' | ||||
= csrf_meta_tags | = csrf_meta_tags | ||||
= yield :header_tags | = yield :header_tags | ||||
%body | |||||
%body{ class: @body_classes } | |||||
= content_for?(:content) ? yield(:content) : yield | = content_for?(:content) ? yield(:content) : yield |
@@ -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" |
@@ -28,12 +28,14 @@ module Mastodon | |||||
config.active_job.queue_adapter = :sidekiq | config.active_job.queue_adapter = :sidekiq | ||||
config.to_prepare do | config.to_prepare do | ||||
Doorkeeper::ApplicationsController.layout 'dashboard' | |||||
Doorkeeper::AuthorizedApplicationsController.layout 'dashboard' | |||||
# Doorkeeper::ApplicationsController.layout 'dashboard' | |||||
# Doorkeeper::AuthorizedApplicationsController.layout 'dashboard' | |||||
Doorkeeper::AuthorizationsController.layout 'auth' | Doorkeeper::AuthorizationsController.layout 'auth' | ||||
end | end | ||||
config.middleware.use Rack::Attack | config.middleware.use Rack::Attack | ||||
config.middleware.use Rack::Deflater | config.middleware.use Rack::Deflater | ||||
config.browserify_rails.commandline_options = "--transform [ babelify --presets [ es2015 react ] ] --extension=\".jsx\"" | |||||
end | end | ||||
end | end |
@@ -63,6 +63,8 @@ Rails.application.configure do | |||||
Bullet.bullet_logger = true | Bullet.bullet_logger = true | ||||
Bullet.rails_logger = true | Bullet.rails_logger = true | ||||
end | end | ||||
config.react.variant = :development | |||||
end | end | ||||
require 'sidekiq/testing' | require 'sidekiq/testing' | ||||
@@ -80,4 +80,6 @@ Rails.application.configure do | |||||
} | } | ||||
config.action_mailer.delivery_method = :smtp | config.action_mailer.delivery_method = :smtp | ||||
config.react.variant = :production | |||||
end | end |
@@ -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" | |||||
} | |||||
} |