@@ -14,6 +14,10 @@ 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 const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST'; | |||
export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS'; | |||
export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL'; | |||
export function setAccountSelf(account) { | |||
return { | |||
type: ACCOUNT_SET_SELF, | |||
@@ -33,6 +37,18 @@ export function fetchAccount(id) { | |||
}; | |||
}; | |||
export function fetchAccountTimeline(id) { | |||
return (dispatch, getState) => { | |||
dispatch(fetchAccountTimelineRequest(id)); | |||
api(getState).get(`/api/accounts/${id}/statuses`).then(response => { | |||
dispatch(fetchAccountTimelineSuccess(id, response.data)); | |||
}).catch(error => { | |||
dispatch(fetchAccountTimelineFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_FETCH_REQUEST, | |||
@@ -120,3 +136,26 @@ export function unfollowAccountFail(error) { | |||
error: error | |||
}; | |||
}; | |||
export function fetchAccountTimelineRequest(id) { | |||
return { | |||
type: ACCOUNT_TIMELINE_FETCH_REQUEST, | |||
id: id | |||
}; | |||
}; | |||
export function fetchAccountTimelineSuccess(id, statuses) { | |||
return { | |||
type: ACCOUNT_TIMELINE_FETCH_SUCCESS, | |||
id: id, | |||
statuses: statuses | |||
}; | |||
}; | |||
export function fetchAccountTimelineFail(id, error) { | |||
return { | |||
type: ACCOUNT_TIMELINE_FETCH_FAIL, | |||
id: id, | |||
error: error | |||
}; | |||
}; |
@@ -1,19 +1,57 @@ | |||
import ColumnHeader from './column_header'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b; | |||
const scrollTop = (node) => { | |||
const startTime = Date.now(); | |||
const offset = node.scrollTop; | |||
const targetY = -offset; | |||
const duration = 1000; | |||
let interrupt = false; | |||
const step = () => { | |||
const elapsed = Date.now() - startTime; | |||
const percentage = elapsed / duration; | |||
if (percentage > 1 || interrupt) { | |||
return; | |||
} | |||
node.scrollTo(0, easingOutQuint(0, elapsed, offset, targetY, duration)); | |||
requestAnimationFrame(step); | |||
}; | |||
step(); | |||
return () => { | |||
interrupt = true; | |||
}; | |||
}; | |||
const Column = React.createClass({ | |||
propTypes: { | |||
heading: React.PropTypes.string, | |||
icon: React.PropTypes.string, | |||
fluid: React.PropTypes.bool | |||
icon: React.PropTypes.string | |||
}, | |||
mixins: [PureRenderMixin], | |||
handleHeaderClick () { | |||
let node = ReactDOM.findDOMNode(this); | |||
node.querySelector('.scrollable').scrollTo(0, 0); | |||
this._interruptScrollAnimation = scrollTop(node.querySelector('.scrollable')); | |||
}, | |||
handleWheel () { | |||
if (typeof this._interruptScrollAnimation !== 'undefined') { | |||
this._interruptScrollAnimation(); | |||
} | |||
}, | |||
handleScroll () { | |||
// todo | |||
}, | |||
render () { | |||
@@ -25,14 +63,8 @@ const Column = React.createClass({ | |||
const style = { width: '350px', flex: '0 0 auto', background: '#282c37', margin: '10px', marginRight: '0', display: 'flex', flexDirection: 'column' }; | |||
if (this.props.fluid) { | |||
style.width = 'auto'; | |||
style.flex = '1 1 auto'; | |||
style.background = '#21242d'; | |||
} | |||
return ( | |||
<div style={style}> | |||
<div style={style} onWheel={this.handleWheel} onScroll={this.handleScroll}> | |||
{header} | |||
{this.props.children} | |||
</div> | |||
@@ -35,7 +35,7 @@ const Frontend = React.createClass({ | |||
<StatusListContainer type='mentions' /> | |||
</Column> | |||
<Column fluid={true}> | |||
<Column> | |||
{this.props.children} | |||
</Column> | |||
</ColumnsArea> | |||
@@ -2,18 +2,7 @@ import { connect } from 'react-redux'; | |||
import StatusList from '../components/status_list'; | |||
import { replyCompose } from '../actions/compose'; | |||
import { reblog, favourite } from '../actions/interactions'; | |||
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; | |||
}; | |||
import { selectStatus } from '../reducers/timelines'; | |||
const mapStateToProps = function (state, props) { | |||
return { | |||
@@ -0,0 +1,35 @@ | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import Button from '../../../components/button'; | |||
const Header = React.createClass({ | |||
propTypes: { | |||
account: ImmutablePropTypes.map.isRequired, | |||
onFollow: React.PropTypes.func.isRequired, | |||
onUnfollow: React.PropTypes.func.isRequired | |||
}, | |||
mixins: [PureRenderMixin], | |||
render () { | |||
const { account } = this.props; | |||
return ( | |||
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover' }}> | |||
<div style={{ background: 'rgba(47, 52, 65, 0.6)', padding: '30px 10px' }}> | |||
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px', borderRadius: '90px', overflow: 'hidden' }} className='transparent-background'> | |||
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} /> | |||
</div> | |||
<span style={{ color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500', display: 'block' }}>{account.get('display_name')}</span> | |||
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span> | |||
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p> | |||
</div> | |||
</div> | |||
); | |||
} | |||
}); | |||
export default Header; |
@@ -1,15 +1,25 @@ | |||
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'; | |||
import { connect } from 'react-redux'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import { fetchAccount, followAccount, unfollowAccount, fetchAccountTimeline } from '../../actions/accounts'; | |||
import { replyCompose } from '../../actions/compose'; | |||
import { favourite, reblog } from '../../actions/interactions'; | |||
import Header from './components/header'; | |||
import { selectStatus } from '../../reducers/timelines'; | |||
import StatusList from '../../components/status_list'; | |||
import Immutable from 'immutable'; | |||
function selectAccount(state, id) { | |||
return state.getIn(['timelines', 'accounts', id], null); | |||
} | |||
}; | |||
function selectStatuses(state, accountId) { | |||
return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null); | |||
}; | |||
const mapStateToProps = (state, props) => ({ | |||
account: selectAccount(state, Number(props.params.accountId)) | |||
account: selectAccount(state, Number(props.params.accountId)), | |||
statuses: selectStatuses(state, Number(props.params.accountId)) | |||
}); | |||
const Account = React.createClass({ | |||
@@ -17,59 +27,55 @@ const Account = React.createClass({ | |||
propTypes: { | |||
params: React.PropTypes.object.isRequired, | |||
dispatch: React.PropTypes.func.isRequired, | |||
account: ImmutablePropTypes.map | |||
account: ImmutablePropTypes.map, | |||
statuses: ImmutablePropTypes.list | |||
}, | |||
mixins: [PureRenderMixin], | |||
componentWillMount () { | |||
this.props.dispatch(fetchAccount(this.props.params.accountId)); | |||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); | |||
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId))); | |||
}, | |||
componentWillReceiveProps(nextProps) { | |||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | |||
this.props.dispatch(fetchAccount(nextProps.params.accountId)); | |||
this.props.dispatch(fetchAccountTimeline(nextProps.params.accountId)); | |||
} | |||
}, | |||
handleFollowClick () { | |||
handleFollow () { | |||
this.props.dispatch(followAccount(this.props.account.get('id'))); | |||
}, | |||
handleUnfollowClick () { | |||
handleUnfollow () { | |||
this.props.dispatch(unfollowAccount(this.props.account.get('id'))); | |||
}, | |||
handleReply (status) { | |||
this.props.dispatch(replyCompose(status)); | |||
}, | |||
handleReblog (status) { | |||
this.props.dispatch(reblog(status)); | |||
}, | |||
handleFavourite (status) { | |||
this.props.dispatch(favourite(status)); | |||
}, | |||
render () { | |||
const { account } = this.props; | |||
let action; | |||
const { account, statuses } = this.props; | |||
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 style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}> | |||
<Header account={account} onFollow={this.handleFollow} onUnfollow={this.handleUnfollow} /> | |||
<StatusList statuses={statuses} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} /> | |||
</div> | |||
); | |||
} | |||
@@ -6,21 +6,10 @@ import Immutable from 'immutable'; | |||
import EmbeddedStatus from '../../components/status'; | |||
import { favourite, reblog } from '../../actions/interactions'; | |||
import { replyCompose } from '../../actions/compose'; | |||
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; | |||
}; | |||
import { selectStatus } from '../../reducers/timelines'; | |||
function selectStatuses(state, ids) { | |||
return ids.map(id => selectStatus(state, id)); | |||
return ids.map(id => selectStatus(state, id)).filterNot(status => status === null); | |||
}; | |||
const mapStateToProps = (state, props) => ({ | |||
@@ -1,20 +1,50 @@ | |||
import { TIMELINE_SET, TIMELINE_UPDATE, TIMELINE_DELETE } from '../actions/timelines'; | |||
import { REBLOG_SUCCESS, FAVOURITE_SUCCESS } from '../actions/interactions'; | |||
import { ACCOUNT_SET_SELF, ACCOUNT_FETCH_SUCCESS, ACCOUNT_FOLLOW_SUCCESS, ACCOUNT_UNFOLLOW_SUCCESS } from '../actions/accounts'; | |||
import { STATUS_FETCH_SUCCESS } from '../actions/statuses'; | |||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow'; | |||
import Immutable from 'immutable'; | |||
import { | |||
TIMELINE_SET, | |||
TIMELINE_UPDATE, | |||
TIMELINE_DELETE | |||
} from '../actions/timelines'; | |||
import { | |||
REBLOG_SUCCESS, | |||
FAVOURITE_SUCCESS | |||
} from '../actions/interactions'; | |||
import { | |||
ACCOUNT_SET_SELF, | |||
ACCOUNT_FETCH_SUCCESS, | |||
ACCOUNT_FOLLOW_SUCCESS, | |||
ACCOUNT_UNFOLLOW_SUCCESS, | |||
ACCOUNT_TIMELINE_FETCH_SUCCESS | |||
} from '../actions/accounts'; | |||
import { STATUS_FETCH_SUCCESS } from '../actions/statuses'; | |||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow'; | |||
import Immutable from 'immutable'; | |||
const initialState = Immutable.Map({ | |||
home: Immutable.List([]), | |||
mentions: Immutable.List([]), | |||
statuses: Immutable.Map(), | |||
accounts: Immutable.Map(), | |||
accounts_timelines: Immutable.Map(), | |||
me: null, | |||
ancestors: Immutable.Map(), | |||
descendants: Immutable.Map() | |||
}); | |||
export function selectStatus(state, id) { | |||
let status = state.getIn(['timelines', 'statuses', id], null); | |||
if (status === null) { | |||
return null; | |||
} | |||
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 statusToMaps(state, status) { | |||
// Separate account | |||
let account = status.get('account'); | |||
@@ -59,6 +89,15 @@ function timelineToMaps(state, timeline, statuses) { | |||
return state; | |||
}; | |||
function accountTimelineToMaps(state, accountId, statuses) { | |||
statuses.forEach((status, i) => { | |||
state = statusToMaps(state, status); | |||
state = state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.set(i, status.get('id'))); | |||
}); | |||
return state; | |||
}; | |||
function updateTimelineWithMaps(state, timeline, status) { | |||
state = statusToMaps(state, status); | |||
state = state.update(timeline, list => list.unshift(status.get('id'))); | |||
@@ -120,6 +159,8 @@ export default function timelines(state = initialState, action) { | |||
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)); | |||
case ACCOUNT_TIMELINE_FETCH_SUCCESS: | |||
return accountTimelineToMaps(state, action.id, Immutable.fromJS(action.statuses)); | |||
default: | |||
return state; | |||
} | |||
@@ -4,6 +4,7 @@ attributes :id, :username, :acct, :display_name, :note | |||
node(:url) { |account| TagManager.instance.url_for(account) } | |||
node(:avatar) { |account| full_asset_url(account.avatar.url(:large, false)) } | |||
node(:header) { |account| full_asset_url(account.header.url(:medium, false)) } | |||
node(:followers_count) { |account| account.followers.count } | |||
node(:following_count) { |account| account.following.count } | |||
node(:statuses_count) { |account| account.statuses.count } | |||