parent
e16c8fbc7a
commit
d61a6271c6
@ -0,0 +1,84 @@ |
||||
import api, { getLinks } from 'flavours/glitch/util/api'; |
||||
import { |
||||
importFetchedAccounts, |
||||
importFetchedStatuses, |
||||
importFetchedStatus, |
||||
} from './importer'; |
||||
|
||||
export const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT'; |
||||
export const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT'; |
||||
|
||||
export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST'; |
||||
export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; |
||||
export const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'; |
||||
export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; |
||||
|
||||
export const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; |
||||
|
||||
export const mountConversations = () => ({ |
||||
type: CONVERSATIONS_MOUNT, |
||||
}); |
||||
|
||||
export const unmountConversations = () => ({ |
||||
type: CONVERSATIONS_UNMOUNT, |
||||
}); |
||||
|
||||
export const markConversationRead = conversationId => (dispatch, getState) => { |
||||
dispatch({ |
||||
type: CONVERSATIONS_READ, |
||||
id: conversationId, |
||||
}); |
||||
|
||||
api(getState).post(`/api/v1/conversations/${conversationId}/read`); |
||||
}; |
||||
|
||||
export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { |
||||
dispatch(expandConversationsRequest()); |
||||
|
||||
const params = { max_id: maxId }; |
||||
|
||||
if (!maxId) { |
||||
params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']); |
||||
} |
||||
|
||||
const isLoadingRecent = !!params.since_id; |
||||
|
||||
api(getState).get('/api/v1/conversations', { params }) |
||||
.then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
|
||||
dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), []))); |
||||
dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x))); |
||||
dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent)); |
||||
}) |
||||
.catch(err => dispatch(expandConversationsFail(err))); |
||||
}; |
||||
|
||||
export const expandConversationsRequest = () => ({ |
||||
type: CONVERSATIONS_FETCH_REQUEST, |
||||
}); |
||||
|
||||
export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({ |
||||
type: CONVERSATIONS_FETCH_SUCCESS, |
||||
conversations, |
||||
next, |
||||
isLoadingRecent, |
||||
}); |
||||
|
||||
export const expandConversationsFail = error => ({ |
||||
type: CONVERSATIONS_FETCH_FAIL, |
||||
error, |
||||
}); |
||||
|
||||
export const updateConversations = conversation => dispatch => { |
||||
dispatch(importFetchedAccounts(conversation.accounts)); |
||||
|
||||
if (conversation.last_status) { |
||||
dispatch(importFetchedStatus(conversation.last_status)); |
||||
} |
||||
|
||||
dispatch({ |
||||
type: CONVERSATIONS_UPDATE, |
||||
conversation, |
||||
}); |
||||
}; |
@ -0,0 +1,104 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { autoPlayGif } from 'flavours/glitch/util/initial_state'; |
||||
|
||||
export default class AvatarComposite extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
accounts: ImmutablePropTypes.list.isRequired, |
||||
animate: PropTypes.bool, |
||||
size: PropTypes.number.isRequired, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
animate: autoPlayGif, |
||||
}; |
||||
|
||||
renderItem (account, size, index) { |
||||
const { animate } = this.props; |
||||
|
||||
let width = 50; |
||||
let height = 100; |
||||
let top = 'auto'; |
||||
let left = 'auto'; |
||||
let bottom = 'auto'; |
||||
let right = 'auto'; |
||||
|
||||
if (size === 1) { |
||||
width = 100; |
||||
} |
||||
|
||||
if (size === 4 || (size === 3 && index > 0)) { |
||||
height = 50; |
||||
} |
||||
|
||||
if (size === 2) { |
||||
if (index === 0) { |
||||
right = '2px'; |
||||
} else { |
||||
left = '2px'; |
||||
} |
||||
} else if (size === 3) { |
||||
if (index === 0) { |
||||
right = '2px'; |
||||
} else if (index > 0) { |
||||
left = '2px'; |
||||
} |
||||
|
||||
if (index === 1) { |
||||
bottom = '2px'; |
||||
} else if (index > 1) { |
||||
top = '2px'; |
||||
} |
||||
} else if (size === 4) { |
||||
if (index === 0 || index === 2) { |
||||
right = '2px'; |
||||
} |
||||
|
||||
if (index === 1 || index === 3) { |
||||
left = '2px'; |
||||
} |
||||
|
||||
if (index < 2) { |
||||
bottom = '2px'; |
||||
} else { |
||||
top = '2px'; |
||||
} |
||||
} |
||||
|
||||
const style = { |
||||
left: left, |
||||
top: top, |
||||
right: right, |
||||
bottom: bottom, |
||||
width: `${width}%`, |
||||
height: `${height}%`, |
||||
backgroundSize: 'cover', |
||||
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, |
||||
}; |
||||
|
||||
return ( |
||||
<a |
||||
href={account.get('url')} |
||||
target='_blank' |
||||
onClick={(e) => this.props.onAccountClick(account.get('id'), e)} |
||||
title={`@${account.get('acct')}`} |
||||
key={account.get('id')} |
||||
> |
||||
<div style={style} data-avatar-of={`@${account.get('acct')}`} /> |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
render() { |
||||
const { accounts, size } = this.props; |
||||
|
||||
return ( |
||||
<div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}> |
||||
{accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,64 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import StatusContainer from 'flavours/glitch/containers/status_container'; |
||||
|
||||
export default class Conversation extends ImmutablePureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
conversationId: PropTypes.string.isRequired, |
||||
accounts: ImmutablePropTypes.list.isRequired, |
||||
lastStatusId: PropTypes.string, |
||||
unread:PropTypes.bool.isRequired, |
||||
onMoveUp: PropTypes.func, |
||||
onMoveDown: PropTypes.func, |
||||
markRead: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
if (!this.context.router) { |
||||
return; |
||||
} |
||||
|
||||
const { lastStatusId, unread, markRead } = this.props; |
||||
|
||||
if (unread) { |
||||
markRead(); |
||||
} |
||||
|
||||
this.context.router.history.push(`/statuses/${lastStatusId}`); |
||||
} |
||||
|
||||
handleHotkeyMoveUp = () => { |
||||
this.props.onMoveUp(this.props.conversationId); |
||||
} |
||||
|
||||
handleHotkeyMoveDown = () => { |
||||
this.props.onMoveDown(this.props.conversationId); |
||||
} |
||||
|
||||
render () { |
||||
const { accounts, lastStatusId, unread } = this.props; |
||||
|
||||
if (lastStatusId === null) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<StatusContainer |
||||
id={lastStatusId} |
||||
unread={unread} |
||||
otherAccounts={accounts} |
||||
onMoveUp={this.handleHotkeyMoveUp} |
||||
onMoveDown={this.handleHotkeyMoveDown} |
||||
onClick={this.handleClick} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,73 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import ConversationContainer from '../containers/conversation_container'; |
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list'; |
||||
import { debounce } from 'lodash'; |
||||
|
||||
export default class ConversationsList extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
conversations: ImmutablePropTypes.list.isRequired, |
||||
hasMore: PropTypes.bool, |
||||
isLoading: PropTypes.bool, |
||||
onLoadMore: PropTypes.func, |
||||
}; |
||||
|
||||
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id) |
||||
|
||||
handleMoveUp = id => { |
||||
const elementIndex = this.getCurrentIndex(id) - 1; |
||||
this._selectChild(elementIndex, true); |
||||
} |
||||
|
||||
handleMoveDown = id => { |
||||
const elementIndex = this.getCurrentIndex(id) + 1; |
||||
this._selectChild(elementIndex, false); |
||||
} |
||||
|
||||
_selectChild (index, align_top) { |
||||
const container = this.node.node; |
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); |
||||
|
||||
if (element) { |
||||
if (align_top && container.scrollTop > element.offsetTop) { |
||||
element.scrollIntoView(true); |
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { |
||||
element.scrollIntoView(false); |
||||
} |
||||
element.focus(); |
||||
} |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.node = c; |
||||
} |
||||
|
||||
handleLoadOlder = debounce(() => { |
||||
const last = this.props.conversations.last(); |
||||
|
||||
if (last && last.get('last_status')) { |
||||
this.props.onLoadMore(last.get('last_status')); |
||||
} |
||||
}, 300, { leading: true }) |
||||
|
||||
render () { |
||||
const { conversations, onLoadMore, ...other } = this.props; |
||||
|
||||
return ( |
||||
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}> |
||||
{conversations.map(item => ( |
||||
<ConversationContainer |
||||
key={item.get('id')} |
||||
conversationId={item.get('id')} |
||||
onMoveUp={this.handleMoveUp} |
||||
onMoveDown={this.handleMoveDown} |
||||
/> |
||||
))} |
||||
</ScrollableList> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,19 @@ |
||||
import { connect } from 'react-redux'; |
||||
import Conversation from '../components/conversation'; |
||||
import { markConversationRead } from '../../../actions/conversations'; |
||||
|
||||
const mapStateToProps = (state, { conversationId }) => { |
||||
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); |
||||
|
||||
return { |
||||
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), |
||||
unread: conversation.get('unread'), |
||||
lastStatusId: conversation.get('last_status', null), |
||||
}; |
||||
}; |
||||
|
||||
const mapDispatchToProps = (dispatch, { conversationId }) => ({ |
||||
markRead: () => dispatch(markConversationRead(conversationId)), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Conversation); |
@ -0,0 +1,15 @@ |
||||
import { connect } from 'react-redux'; |
||||
import ConversationsList from '../components/conversations_list'; |
||||
import { expandConversations } from 'flavours/glitch/actions/conversations'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
conversations: state.getIn(['conversations', 'items']), |
||||
isLoading: state.getIn(['conversations', 'isLoading'], true), |
||||
hasMore: state.getIn(['conversations', 'hasMore'], false), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
onLoadMore: maxId => dispatch(expandConversations({ maxId })), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList); |
@ -0,0 +1,102 @@ |
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; |
||||
import { |
||||
CONVERSATIONS_MOUNT, |
||||
CONVERSATIONS_UNMOUNT, |
||||
CONVERSATIONS_FETCH_REQUEST, |
||||
CONVERSATIONS_FETCH_SUCCESS, |
||||
CONVERSATIONS_FETCH_FAIL, |
||||
CONVERSATIONS_UPDATE, |
||||
CONVERSATIONS_READ, |
||||
} from '../actions/conversations'; |
||||
import compareId from 'flavours/glitch/util/compare_id'; |
||||
|
||||
const initialState = ImmutableMap({ |
||||
items: ImmutableList(), |
||||
isLoading: false, |
||||
hasMore: true, |
||||
mounted: 0, |
||||
}); |
||||
|
||||
const conversationToMap = item => ImmutableMap({ |
||||
id: item.id, |
||||
unread: item.unread, |
||||
accounts: ImmutableList(item.accounts.map(a => a.id)), |
||||
last_status: item.last_status ? item.last_status.id : null, |
||||
}); |
||||
|
||||
const updateConversation = (state, item) => state.update('items', list => { |
||||
const index = list.findIndex(x => x.get('id') === item.id); |
||||
const newItem = conversationToMap(item); |
||||
|
||||
if (index === -1) { |
||||
return list.unshift(newItem); |
||||
} else { |
||||
return list.set(index, newItem); |
||||
} |
||||
}); |
||||
|
||||
const expandNormalizedConversations = (state, conversations, next, isLoadingRecent) => { |
||||
let items = ImmutableList(conversations.map(conversationToMap)); |
||||
|
||||
return state.withMutations(mutable => { |
||||
if (!items.isEmpty()) { |
||||
mutable.update('items', list => { |
||||
list = list.map(oldItem => { |
||||
const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id')); |
||||
|
||||
if (newItemIndex === -1) { |
||||
return oldItem; |
||||
} |
||||
|
||||
const newItem = items.get(newItemIndex); |
||||
items = items.delete(newItemIndex); |
||||
|
||||
return newItem; |
||||
}); |
||||
|
||||
list = list.concat(items); |
||||
|
||||
return list.sortBy(x => x.get('last_status'), (a, b) => { |
||||
if(a === null || b === null) { |
||||
return -1; |
||||
} |
||||
|
||||
return compareId(a, b) * -1; |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
if (!next && !isLoadingRecent) { |
||||
mutable.set('hasMore', false); |
||||
} |
||||
|
||||
mutable.set('isLoading', false); |
||||
}); |
||||
}; |
||||
|
||||
export default function conversations(state = initialState, action) { |
||||
switch (action.type) { |
||||
case CONVERSATIONS_FETCH_REQUEST: |
||||
return state.set('isLoading', true); |
||||
case CONVERSATIONS_FETCH_FAIL: |
||||
return state.set('isLoading', false); |
||||
case CONVERSATIONS_FETCH_SUCCESS: |
||||
return expandNormalizedConversations(state, action.conversations, action.next, action.isLoadingRecent); |
||||
case CONVERSATIONS_UPDATE: |
||||
return updateConversation(state, action.conversation); |
||||
case CONVERSATIONS_MOUNT: |
||||
return state.update('mounted', count => count + 1); |
||||
case CONVERSATIONS_UNMOUNT: |
||||
return state.update('mounted', count => count - 1); |
||||
case CONVERSATIONS_READ: |
||||
return state.update('items', list => list.map(item => { |
||||
if (item.get('id') === action.id) { |
||||
return item.set('unread', false); |
||||
} |
||||
|
||||
return item; |
||||
})); |
||||
default: |
||||
return state; |
||||
} |
||||
}; |
Loading…
Reference in new issue