@@ -1,11 +1,22 @@ | |||
import api from '../api' | |||
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF'; | |||
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF'; | |||
export const ACCOUNT_FETCH = 'ACCOUNT_FETCH'; | |||
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; | |||
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; | |||
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; | |||
export const ACCOUNT_FOLLOW = 'ACCOUNT_FOLLOW'; | |||
export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; | |||
export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; | |||
export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; | |||
export const ACCOUNT_UNFOLLOW = 'ACCOUNT_UNFOLLOW'; | |||
export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; | |||
export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; | |||
export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; | |||
export function setAccountSelf(account) { | |||
return { | |||
type: ACCOUNT_SET_SELF, | |||
@@ -46,3 +57,69 @@ export function fetchAccountFail(id, error) { | |||
error: error | |||
}; | |||
}; | |||
export function followAccount(id) { | |||
return (dispatch, getState) => { | |||
dispatch(followAccountRequest(id)); | |||
api(getState).post(`/api/accounts/${id}/follow`).then(response => { | |||
dispatch(followAccountSuccess(response.data)); | |||
}).catch(error => { | |||
dispatch(followAccountFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function unfollowAccount(id) { | |||
return (dispatch, getState) => { | |||
dispatch(unfollowAccountRequest(id)); | |||
api(getState).post(`/api/accounts/${id}/unfollow`).then(response => { | |||
dispatch(unfollowAccountSuccess(response.data)); | |||
}).catch(error => { | |||
dispatch(unfollowAccountFail(error)); | |||
}); | |||
} | |||
}; | |||
export function followAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_FOLLOW_REQUEST, | |||
id: id | |||
}; | |||
}; | |||
export function followAccountSuccess(account) { | |||
return { | |||
type: ACCOUNT_FOLLOW_SUCCESS, | |||
account: account | |||
}; | |||
}; | |||
export function followAccountFail(error) { | |||
return { | |||
type: ACCOUNT_FOLLOW_FAIL, | |||
error: error | |||
}; | |||
}; | |||
export function unfollowAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_UNFOLLOW_REQUEST, | |||
id: id | |||
}; | |||
}; | |||
export function unfollowAccountSuccess(account) { | |||
return { | |||
type: ACCOUNT_UNFOLLOW_SUCCESS, | |||
account: account | |||
}; | |||
}; | |||
export function unfollowAccountFail(error) { | |||
return { | |||
type: ACCOUNT_UNFOLLOW_FAIL, | |||
error: error | |||
}; | |||
}; |
@@ -1,6 +1,44 @@ | |||
import api from '../api'; | |||
import api from '../api'; | |||
import axios from 'axios'; | |||
export const STATUS_FETCH = 'STATUS_FETCH'; | |||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; | |||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; | |||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; | |||
export function fetchStatusRequest(id) { | |||
return { | |||
type: STATUS_FETCH_REQUEST, | |||
id: id | |||
}; | |||
}; | |||
export function fetchStatus(id) { | |||
return (dispatch, getState) => { | |||
const boundApi = api(getState); | |||
dispatch(fetchStatusRequest(id)); | |||
axios.all([boundApi.get(`/api/statuses/${id}`), boundApi.get(`/api/statuses/${id}/context`)]).then(values => { | |||
dispatch(fetchStatusSuccess(values[0].data, values[1].data)); | |||
}).catch(error => { | |||
dispatch(fetchStatusFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchStatusSuccess(status, context) { | |||
return { | |||
type: STATUS_FETCH_SUCCESS, | |||
status: status, | |||
context: context | |||
}; | |||
}; | |||
export function fetchStatusFail(id, error) { | |||
return { | |||
type: STATUS_FETCH_FAIL, | |||
id: id, | |||
error: error | |||
}; | |||
}; |
@@ -15,7 +15,7 @@ const NavigationBar = React.createClass({ | |||
render () { | |||
return ( | |||
<div style={{ padding: '10px', display: 'flex', cursor: 'default' }}> | |||
<Avatar src={this.props.account.get('avatar')} size={40} /> | |||
<Link to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Link> | |||
<div style={{ flex: '1 1 auto', marginLeft: '8px' }}> | |||
<strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong> | |||
@@ -32,7 +32,16 @@ const Status = React.createClass({ | |||
}, | |||
handleClick () { | |||
hashHistory.push(`/statuses/${this.props.status.get('id')}`); | |||
const { status } = this.props; | |||
hashHistory.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); | |||
}, | |||
handleAccountClick (id, e) { | |||
if (e.button === 0) { | |||
e.preventDefault(); | |||
hashHistory.push(`/accounts/${id}`); | |||
e.stopPropagation(); | |||
} | |||
}, | |||
render () { | |||
@@ -46,7 +55,7 @@ const Status = React.createClass({ | |||
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}> | |||
<div style={{ marginLeft: '68px', color: '#616b86', padding: '8px 0', paddingBottom: '2px', fontSize: '14px', position: 'relative' }}> | |||
<div style={{ position: 'absolute', 'left': '-26px'}}><i className='fa fa-fw fa-retweet'></i></div> | |||
<a href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{status.getIn(['account', 'display_name'])}</strong></a> reblogged | |||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{status.getIn(['account', 'display_name'])}</strong></a> reblogged | |||
</div> | |||
<Status {...other} wrapped={true} status={status.get('reblog')} /> | |||
@@ -65,7 +74,7 @@ const Status = React.createClass({ | |||
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }}><RelativeTimestamp timestamp={status.get('created_at')} /></a> | |||
</div> | |||
<a href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}> | |||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}> | |||
<div style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}> | |||
<Avatar src={status.getIn(['account', 'avatar'])} size={48} /> | |||
</div> | |||
@@ -6,6 +6,10 @@ import { setAccessToken } fro | |||
import { setAccountSelf } from '../actions/accounts'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import { Router, Route, hashHistory } from 'react-router'; | |||
import Account from '../features/account'; | |||
import Settings from '../features/settings'; | |||
import Status from '../features/status'; | |||
import Subscriptions from '../features/subscriptions'; | |||
const store = configureStore(); | |||
@@ -55,10 +59,10 @@ const Root = React.createClass({ | |||
<Provider store={store}> | |||
<Router history={hashHistory}> | |||
<Route path='/' component={Frontend}> | |||
<Route path='/settings' component={null} /> | |||
<Route path='/subscriptions' component={null} /> | |||
<Route path='/statuses/:statusId' component={null} /> | |||
<Route path='/accounts/:accountId' component={null} /> | |||
<Route path='/settings' component={Settings} /> | |||
<Route path='/subscriptions' component={Subscriptions} /> | |||
<Route path='/statuses/:statusId' component={Status} /> | |||
<Route path='/accounts/:accountId' component={Account} /> | |||
</Route> | |||
</Router> | |||
</Provider> | |||
@@ -0,0 +1,79 @@ | |||
import { connect } from 'react-redux'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import { fetchAccount, followAccount, unfollowAccount } from '../../actions/accounts'; | |||
import Button from '../../components/button'; | |||
function selectAccount(state, id) { | |||
return state.getIn(['timelines', 'accounts', id], null); | |||
} | |||
const mapStateToProps = (state, props) => ({ | |||
account: selectAccount(state, Number(props.params.accountId)) | |||
}); | |||
const Account = React.createClass({ | |||
propTypes: { | |||
params: React.PropTypes.object.isRequired, | |||
dispatch: React.PropTypes.func.isRequired, | |||
account: ImmutablePropTypes.map | |||
}, | |||
mixins: [PureRenderMixin], | |||
componentWillMount () { | |||
this.props.dispatch(fetchAccount(this.props.params.accountId)); | |||
}, | |||
componentWillReceiveProps(nextProps) { | |||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | |||
this.props.dispatch(fetchAccount(nextProps.params.accountId)); | |||
} | |||
}, | |||
handleFollowClick () { | |||
this.props.dispatch(followAccount(this.props.account.get('id'))); | |||
}, | |||
handleUnfollowClick () { | |||
this.props.dispatch(unfollowAccount(this.props.account.get('id'))); | |||
}, | |||
render () { | |||
const { account } = this.props; | |||
let action; | |||
if (account === null) { | |||
return <div>Loading {this.props.params.accountId}...</div>; | |||
} | |||
if (account.get('following')) { | |||
action = <Button text='Unfollow' onClick={this.handleUnfollowClick} />; | |||
} else { | |||
action = <Button text='Follow' onClick={this.handleFollowClick} /> | |||
} | |||
return ( | |||
<div> | |||
<p> | |||
{account.get('display_name')} | |||
{account.get('acct')} | |||
</p> | |||
{account.get('url')} | |||
<p>{account.get('note')}</p> | |||
{account.get('followers_count')} followers<br /> | |||
{account.get('following_count')} following<br /> | |||
{account.get('statuses_count')} posts | |||
<p>{action}</p> | |||
</div> | |||
); | |||
} | |||
}); | |||
export default connect(mapStateToProps)(Account); |
@@ -0,0 +1,28 @@ | |||
import { connect } from 'react-redux'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
const mapStateToProps = (state, props) => ({ | |||
}); | |||
const Settings = React.createClass({ | |||
propTypes: { | |||
params: React.PropTypes.object.isRequired, | |||
dispatch: React.PropTypes.func.isRequired | |||
}, | |||
mixins: [PureRenderMixin], | |||
componentWillMount () { | |||
// | |||
}, | |||
render () { | |||
return <div>Settings</div>; | |||
} | |||
}); | |||
export default connect(mapStateToProps)(Settings); |
@@ -0,0 +1,74 @@ | |||
import { connect } from 'react-redux'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import { fetchStatus } from '../../actions/statuses'; | |||
import Immutable from 'immutable'; | |||
import EmbeddedStatus from '../../components/status'; | |||
function selectStatus(state, id) { | |||
let status = state.getIn(['timelines', 'statuses', id]); | |||
status = status.set('account', state.getIn(['timelines', 'accounts', status.get('account')])); | |||
if (status.get('reblog') !== null) { | |||
status = status.set('reblog', selectStatus(state, status.get('reblog'))); | |||
} | |||
return status; | |||
}; | |||
function selectStatuses(state, ids) { | |||
return ids.map(id => selectStatus(state, id)); | |||
}; | |||
const mapStateToProps = (state, props) => ({ | |||
status: selectStatus(state, Number(props.params.statusId)), | |||
ancestors: selectStatuses(state, state.getIn(['timelines', 'ancestors', Number(props.params.statusId)], Immutable.List())), | |||
descendants: selectStatuses(state, state.getIn(['timelines', 'descendants', Number(props.params.statusId)], Immutable.List())) | |||
}); | |||
const Status = React.createClass({ | |||
propTypes: { | |||
params: React.PropTypes.object.isRequired, | |||
dispatch: React.PropTypes.func.isRequired, | |||
status: ImmutablePropTypes.map, | |||
ancestors: ImmutablePropTypes.list.isRequired, | |||
descendants: ImmutablePropTypes.list.isRequired | |||
}, | |||
mixins: [PureRenderMixin], | |||
componentWillMount () { | |||
this.props.dispatch(fetchStatus(this.props.params.statusId)); | |||
}, | |||
componentWillReceiveProps (nextProps) { | |||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { | |||
this.props.dispatch(fetchStatus(nextProps.params.statusId)); | |||
} | |||
}, | |||
renderChildren (list) { | |||
return list.map(s => <EmbeddedStatus status={s} key={s.get('id')} />); | |||
}, | |||
render () { | |||
const { status, ancestors, descendants } = this.props; | |||
if (status === null) { | |||
return <div>Loading {this.props.params.statusId}...</div>; | |||
} | |||
return ( | |||
<div> | |||
{this.renderChildren(ancestors)} | |||
<EmbeddedStatus status={status} /> | |||
{this.renderChildren(descendants)} | |||
</div> | |||
); | |||
} | |||
}); | |||
export default connect(mapStateToProps)(Status); |
@@ -0,0 +1,28 @@ | |||
import { connect } from 'react-redux'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
const mapStateToProps = (state, props) => ({ | |||
}); | |||
const Subscriptions = React.createClass({ | |||
propTypes: { | |||
params: React.PropTypes.object.isRequired, | |||
dispatch: React.PropTypes.func.isRequired | |||
}, | |||
mixins: [PureRenderMixin], | |||
componentWillMount () { | |||
// | |||
}, | |||
render () { | |||
return <div>Subscriptions</div>; | |||
} | |||
}); | |||
export default connect(mapStateToProps)(Subscriptions); |
@@ -1,6 +1,7 @@ | |||
import { TIMELINE_SET, TIMELINE_UPDATE, TIMELINE_DELETE } from '../actions/timelines'; | |||
import { REBLOG_SUCCESS, FAVOURITE_SUCCESS } from '../actions/interactions'; | |||
import { ACCOUNT_SET_SELF } from '../actions/accounts'; | |||
import { ACCOUNT_SET_SELF, ACCOUNT_FETCH_SUCCESS } from '../actions/accounts'; | |||
import { STATUS_FETCH_SUCCESS } from '../actions/statuses'; | |||
import Immutable from 'immutable'; | |||
const initialState = Immutable.Map({ | |||
@@ -8,7 +9,9 @@ const initialState = Immutable.Map({ | |||
mentions: Immutable.List([]), | |||
statuses: Immutable.Map(), | |||
accounts: Immutable.Map(), | |||
me: null | |||
me: null, | |||
ancestors: Immutable.Map(), | |||
descendants: Immutable.Map() | |||
}); | |||
function statusToMaps(state, status) { | |||
@@ -54,6 +57,29 @@ function deleteStatus(state, id) { | |||
return state.deleteIn(['statuses', id]); | |||
}; | |||
function accountToMaps(state, account) { | |||
return state.setIn(['accounts', account.get('id')], account); | |||
}; | |||
function contextToMaps(state, status, ancestors, descendants) { | |||
state = statusToMaps(state, status); | |||
let ancestorsIds = ancestors.map(ancestor => { | |||
state = statusToMaps(state, ancestor); | |||
return ancestor.get('id'); | |||
}); | |||
let descendantsIds = descendants.map(descendant => { | |||
state = statusToMaps(state, descendant); | |||
return descendant.get('id'); | |||
}); | |||
return state.withMutations(map => { | |||
map.setIn(['ancestors', status.get('id')], ancestorsIds); | |||
map.setIn(['descendants', status.get('id')], descendantsIds); | |||
}); | |||
}; | |||
export default function timelines(state = initialState, action) { | |||
switch(action.type) { | |||
case TIMELINE_SET: | |||
@@ -70,6 +96,10 @@ export default function timelines(state = initialState, action) { | |||
map.setIn(['accounts', action.account.id], Immutable.fromJS(action.account)); | |||
map.set('me', action.account.id); | |||
}); | |||
case ACCOUNT_FETCH_SUCCESS: | |||
return accountToMaps(state, Immutable.fromJS(action.account)); | |||
case STATUS_FETCH_SUCCESS: | |||
return contextToMaps(state, Immutable.fromJS(action.status), Immutable.fromJS(action.context.ancestors), Immutable.fromJS(action.context.descendants)); | |||
default: | |||
return state; | |||
} | |||
@@ -6,6 +6,12 @@ class Api::StatusesController < ApiController | |||
@status = Status.find(params[:id]) | |||
end | |||
def context | |||
@status = Status.find(params[:id]) | |||
@ancestors = @status.ancestors.with_includes.with_counters | |||
@descendants = @status.descendants.with_includes.with_counters | |||
end | |||
def create | |||
@status = PostStatusService.new.(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), params[:media_ids]) | |||
render action: :show | |||
@@ -0,0 +1,13 @@ | |||
object false | |||
node :ancestors do | |||
@ancestors.map do |status| | |||
partial('api/statuses/show', object: status) | |||
end | |||
end | |||
node :descendants do | |||
@descendants.map do |status| | |||
partial('api/statuses/show', object: status) | |||
end | |||
end |
@@ -47,6 +47,8 @@ Rails.application.routes.draw do | |||
end | |||
member do | |||
get :context | |||
post :reblog | |||
post :favourite | |||
end | |||