Adding React.js, Redux, revamping dashboard
This commit is contained in:
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -20,3 +20,4 @@ public/system | ||||
| public/assets | ||||
| .env | ||||
| .env.* | ||||
| node_modules/ | ||||
|   | ||||
							
								
								
									
										3
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								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' | ||||
|   | ||||
							
								
								
									
										16
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								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 | ||||
|   | ||||
| @@ -12,4 +12,6 @@ | ||||
| // | ||||
| //= require jquery | ||||
| //= 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)); | ||||
|   } | ||||
| }); | ||||
							
								
								
									
										9
									
								
								app/assets/javascripts/components.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/assets/javascripts/components.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								app/assets/javascripts/components/.gitkeep
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/assets/javascripts/components/.gitkeep
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										18
									
								
								app/assets/javascripts/components/actions/statuses.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/assets/javascripts/components/actions/statuses.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										19
									
								
								app/assets/javascripts/components/components/column.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/assets/javascripts/components/components/column.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
							
								
								
									
										16
									
								
								app/assets/javascripts/components/components/frontend.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/assets/javascripts/components/components/frontend.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
							
								
								
									
										8
									
								
								app/assets/javascripts/components/components/nav_bar.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/assets/javascripts/components/components/nav_bar.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| const NavBar = React.createClass({ | ||||
|  | ||||
|   render: function() { | ||||
|     return <div style={{ background: '#2f3441', width: '60px', margin: '10px', marginRight: '0' }} />; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| export default NavBar; | ||||
							
								
								
									
										19
									
								
								app/assets/javascripts/components/components/status.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/assets/javascripts/components/components/status.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
							
								
								
									
										22
									
								
								app/assets/javascripts/components/components/status_list.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/assets/javascripts/components/components/status_list.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
							
								
								
									
										40
									
								
								app/assets/javascripts/components/containers/root.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								app/assets/javascripts/components/containers/root.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
							
								
								
									
										6
									
								
								app/assets/javascripts/components/reducers/index.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								app/assets/javascripts/components/reducers/index.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| import { combineReducers } from 'redux-immutable'; | ||||
| import statuses            from './statuses'; | ||||
|  | ||||
| export default combineReducers({ | ||||
|   statuses | ||||
| }); | ||||
							
								
								
									
										17
									
								
								app/assets/javascripts/components/reducers/statuses.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/assets/javascripts/components/reducers/statuses.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|   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 { | ||||
|   | ||||
| @@ -12,6 +12,8 @@ class ApplicationController < ActionController::Base | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   helper_method :current_account | ||||
|  | ||||
|   protected | ||||
|  | ||||
|   def current_account | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -1,6 +1,4 @@ | ||||
| class SettingsController < ApplicationController | ||||
|   layout 'dashboard' | ||||
|  | ||||
|   before_action :authenticate_user! | ||||
|   before_action :set_account | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,4 @@ | ||||
| class StatusesController < ApplicationController | ||||
|   layout 'dashboard' | ||||
|  | ||||
|   before_action :authenticate_user! | ||||
|  | ||||
|   def create | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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' | ||||
|     = csrf_meta_tags | ||||
|     = yield :header_tags | ||||
|   %body | ||||
|   %body{ class: @body_classes } | ||||
|     = 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.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 | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
| @@ -80,4 +80,6 @@ Rails.application.configure do | ||||
|   } | ||||
|  | ||||
|   config.action_mailer.delivery_method = :smtp | ||||
|  | ||||
|   config.react.variant = :production | ||||
| end | ||||
|   | ||||
							
								
								
									
										20
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user