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