diff options
20 files changed, 704 insertions, 69 deletions
diff --git a/app/javascript/flavours/glitch/actions/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js new file mode 100644 index 000000000..856f8f10f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/conversations.js @@ -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, + }); +}; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index b5dd70989..21379f492 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -7,6 +7,7 @@ import { disconnectTimeline, } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; +import { updateConversations } from './conversations'; import { fetchFilters } from './filters'; import { getLocale } from 'mastodon/locales'; @@ -37,6 +38,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, case 'notification': dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); break; + case 'conversation': + dispatch(updateConversations(JSON.parse(data.payload))); + break; case 'filters_changed': dispatch(fetchFilters()); break; diff --git a/app/javascript/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js new file mode 100644 index 000000000..c52df043a --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar_composite.js @@ -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> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js index a26cff049..7f6ef5a5d 100644 --- a/app/javascript/flavours/glitch/components/display_name.js +++ b/app/javascript/flavours/glitch/components/display_name.js @@ -10,24 +10,56 @@ export default function DisplayName ({ className, inline, localDomain, + others, + onAccountClick, }) { const computedClass = classNames('display-name', { inline }, className); if (!account) return null; + let displayName, suffix; + let acct = account.get('acct'); + if (acct.indexOf('@') === -1 && localDomain) { acct = `${acct}@${localDomain}`; } - // The result. - return account ? ( + if (others && others.size > 0) { + displayName = others.take(2).map(a => ( + <a + href={a.get('url')} + target='_blank' + onClick={(e) => onAccountClick(a.get('id'), e)} + title={`@${a.get('acct')}`} + > + <bdi key={a.get('id')}> + <strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /> + </bdi> + </a> + )).reduce((prev, cur) => [prev, ', ', cur]); + + if (others.size - 2 > 0) { + displayName.push(` +${others.size - 2}`); + } + + suffix = ( + <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)}> + <span className='display-name__account'>@{acct}</span> + </a> + ); + } else { + displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>; + suffix = <span className='display-name__account'>@{acct}</span>; + } + + return ( <span className={computedClass}> - <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> + {displayName} {inline ? ' ' : null} - <span className='display-name__account'>@{acct}</span> + {suffix} </span> - ) : null; + ); } // Props. @@ -36,4 +68,6 @@ DisplayName.propTypes = { className: PropTypes.string, inline: PropTypes.bool, localDomain: PropTypes.string, + others: ImmutablePropTypes.list, + handleClick: PropTypes.func, }; diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 7014cab17..4b9364ae5 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -66,6 +66,7 @@ export default class Status extends ImmutablePureComponent { containerId: PropTypes.string, id: PropTypes.string, status: ImmutablePropTypes.map, + otherAccounts: ImmutablePropTypes.list, account: ImmutablePropTypes.map, onReply: PropTypes.func, onFavourite: PropTypes.func, @@ -83,6 +84,7 @@ export default class Status extends ImmutablePureComponent { muted: PropTypes.bool, collapse: PropTypes.bool, hidden: PropTypes.bool, + unread: PropTypes.bool, prepend: PropTypes.string, withDismiss: PropTypes.bool, onMoveUp: PropTypes.func, @@ -93,6 +95,7 @@ export default class Status extends ImmutablePureComponent { intl: PropTypes.object.isRequired, cacheMediaWidth: PropTypes.func, cachedMediaWidth: PropTypes.number, + onClick: PropTypes.func, }; state = { @@ -321,17 +324,21 @@ export default class Status extends ImmutablePureComponent { const { status } = this.props; const { isCollapsed } = this.state; if (!router) return; - if (destination === undefined) { - destination = `/statuses/${ - status.getIn(['reblog', 'id'], status.get('id')) - }`; - } + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { if (isCollapsed) this.setCollapsed(false); else if (e.shiftKey) { this.setCollapsed(true); document.getSelection().removeAllRanges(); + } else if (this.props.onClick) { + this.props.onClick(); + return; } else { + if (destination === undefined) { + destination = `/statuses/${ + status.getIn(['reblog', 'id'], status.get('id')) + }`; + } let state = {...router.history.location.state}; state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; router.history.push(destination, state); @@ -441,6 +448,7 @@ export default class Status extends ImmutablePureComponent { intl, status, account, + otherAccounts, settings, collapsed, muted, @@ -450,6 +458,7 @@ export default class Status extends ImmutablePureComponent { onOpenMedia, notification, hidden, + unread, featured, ...other } = this.props; @@ -617,6 +626,7 @@ export default class Status extends ImmutablePureComponent { collapsed: isCollapsed, 'has-background': isCollapsed && background, 'status__wrapper-reply': !!status.get('in_reply_to_id'), + read: unread === false, muted, }, 'focusable'); @@ -647,6 +657,7 @@ export default class Status extends ImmutablePureComponent { friend={account} collapsed={isCollapsed} parseClick={parseClick} + otherAccounts={otherAccounts} /> ) : null} </span> @@ -656,6 +667,7 @@ export default class Status extends ImmutablePureComponent { collapsible={settings.getIn(['collapsed', 'enabled'])} collapsed={isCollapsed} setCollapsed={setCollapsed} + directMessage={!!otherAccounts} /> </header> <StatusContent @@ -673,6 +685,7 @@ export default class Status extends ImmutablePureComponent { status={status} account={status.get('account')} showReplyCount={settings.get('show_reply_count')} + directMessage={!!otherAccounts} /> ) : null} {notification ? ( diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 4c398fd19..85bc4a976 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -71,6 +71,7 @@ export default class StatusActionBar extends ImmutablePureComponent { onBookmark: PropTypes.func, withDismiss: PropTypes.bool, showReplyCount: PropTypes.bool, + directMessage: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -191,7 +192,7 @@ export default class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, intl, withDismiss, showReplyCount } = this.props; + const { status, intl, withDismiss, showReplyCount, directMessage } = this.props; const mutingConversation = status.get('muted'); const anonymousAccess = !me; @@ -282,14 +283,15 @@ export default class StatusActionBar extends ImmutablePureComponent { return ( <div className='status__action-bar'> {replyButton} - <IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} /> - <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> - {shareButton} - <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /> - - <div className='status__action-bar-dropdown'> - <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> - </div> + {!directMessage && [ + <IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />, + <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />, + shareButton, + <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />, + <div className='status__action-bar-dropdown'> + <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> + </div>, + ]} <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> </div> diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js index f9321904c..23cff286a 100644 --- a/app/javascript/flavours/glitch/components/status_header.js +++ b/app/javascript/flavours/glitch/components/status_header.js @@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; // Mastodon imports. import Avatar from './avatar'; import AvatarOverlay from './avatar_overlay'; +import AvatarComposite from './avatar_composite'; import DisplayName from './display_name'; export default class StatusHeader extends React.PureComponent { @@ -14,12 +15,18 @@ export default class StatusHeader extends React.PureComponent { status: ImmutablePropTypes.map.isRequired, friend: ImmutablePropTypes.map, parseClick: PropTypes.func.isRequired, + otherAccounts: ImmutablePropTypes.list, }; // Handles clicks on account name/image + handleClick = (id, e) => { + const { parseClick } = this.props; + parseClick(e, `/accounts/${id}`); + } + handleAccountClick = (e) => { - const { status, parseClick } = this.props; - parseClick(e, `/accounts/${status.getIn(['account', 'id'])}`); + const { status } = this.props; + this.handleClick(status.getIn(['account', 'id']), e); } // Rendering. @@ -27,36 +34,55 @@ export default class StatusHeader extends React.PureComponent { const { status, friend, + otherAccounts, } = this.props; const account = status.get('account'); - return ( - <div className='status__info__account' > - <a - href={account.get('url')} - target='_blank' - className='status__avatar' - onClick={this.handleAccountClick} - > - { - friend ? ( - <AvatarOverlay account={account} friend={friend} /> - ) : ( - <Avatar account={account} size={48} /> - ) - } - </a> - <a - href={account.get('url')} - target='_blank' - className='status__display-name' - onClick={this.handleAccountClick} - > - <DisplayName account={account} /> - </a> - </div> - ); + let statusAvatar; + if (otherAccounts && otherAccounts.size > 0) { + statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} onAccountClick={this.handleClick} />; + } else if (friend === undefined || friend === null) { + statusAvatar = <Avatar account={account} size={48} />; + } else { + statusAvatar = <AvatarOverlay account={account} friend={friend} />; + } + + if (!otherAccounts) { + return ( + <div className='status__info__account'> + <a + href={account.get('url')} + target='_blank' + className='status__avatar' + onClick={this.handleAccountClick} + > + {statusAvatar} + </a> + <a + href={account.get('url')} + target='_blank' + className='status__display-name' + onClick={this.handleAccountClick} + > + <DisplayName account={account} others={otherAccounts} /> + </a> + </div> + ); + } else { + // This is a DM conversation + return ( + <div className='status__info__account'> + <span className='status__avatar'> + {statusAvatar} + </span> + + <span className='status__display-name'> + <DisplayName account={account} others={otherAccounts} onAccountClick={this.handleClick} /> + </span> + </div> + ); + } } } diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js index c9747650f..4a2c62881 100644 --- a/app/javascript/flavours/glitch/components/status_icons.js +++ b/app/javascript/flavours/glitch/components/status_icons.js @@ -22,6 +22,7 @@ export default class StatusIcons extends React.PureComponent { mediaIcon: PropTypes.string, collapsible: PropTypes.bool, collapsed: PropTypes.bool, + directMessage: PropTypes.bool, setCollapsed: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; @@ -42,6 +43,7 @@ export default class StatusIcons extends React.PureComponent { mediaIcon, collapsible, collapsed, + directMessage, intl, } = this.props; @@ -59,9 +61,7 @@ export default class StatusIcons extends React.PureComponent { aria-hidden='true' /> ) : null} - {( - <VisibilityIcon visibility={status.get('visibility')} /> - )} + {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />} {collapsible ? ( <IconButton className='status__collapse-button' diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js new file mode 100644 index 000000000..9ddeabe75 --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js @@ -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} + /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js new file mode 100644 index 000000000..4fa76fd6d --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js @@ -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> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js new file mode 100644 index 000000000..bd6f6bfb0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js @@ -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); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js new file mode 100644 index 000000000..e10558f3a --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js @@ -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); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js index dc7e0534d..6fe8a1ce8 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/index.js +++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js @@ -5,10 +5,13 @@ import StatusListContainer from 'flavours/glitch/features/ui/containers/status_l import Column from 'flavours/glitch/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; import { expandDirectTimeline } from 'flavours/glitch/actions/timelines'; +import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import { connectDirectStream } from 'flavours/glitch/actions/streaming'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import ConversationsListContainer from './containers/conversations_list_container'; const messages = defineMessages({ title: { id: 'column.direct', defaultMessage: 'Direct messages' }, @@ -16,6 +19,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, + conversationsMode: state.getIn(['settings', 'direct', 'conversations']), }); @connect(mapStateToProps) @@ -28,6 +32,7 @@ export default class DirectTimeline extends React.PureComponent { intl: PropTypes.object.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, + conversationsMode: PropTypes.bool, }; handlePin = () => { @@ -50,13 +55,32 @@ export default class DirectTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch } = this.props; + const { dispatch, conversationsMode } = this.props; + + dispatch(mountConversations()); + + if (conversationsMode) { + dispatch(expandConversations()); + } else { + dispatch(expandDirectTimeline()); + } - dispatch(expandDirectTimeline()); this.disconnect = dispatch(connectDirectStream()); } + componentDidUpdate(prevProps) { + const { dispatch, conversationsMode } = this.props; + + if (prevProps.conversationsMode && !conversationsMode) { + dispatch(expandDirectTimeline()); + } else if (!prevProps.conversationsMode && conversationsMode) { + dispatch(expandConversations()); + } + } + componentWillUnmount () { + this.props.dispatch(unmountConversations()); + if (this.disconnect) { this.disconnect(); this.disconnect = null; @@ -67,14 +91,49 @@ export default class DirectTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = maxId => { + handleLoadMoreTimeline = maxId => { this.props.dispatch(expandDirectTimeline({ maxId })); } + handleLoadMoreConversations = maxId => { + this.props.dispatch(expandConversations({ maxId })); + } + + handleTimelineClick = () => { + this.props.dispatch(changeSetting(['direct', 'conversations'], false)); + } + + handleConversationsClick = () => { + this.props.dispatch(changeSetting(['direct', 'conversations'], true)); + } + render () { - const { intl, hasUnread, columnId, multiColumn } = this.props; + const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props; const pinned = !!columnId; + let contents; + if (conversationsMode) { + contents = ( + <ConversationsListContainer + trackScroll={!pinned} + scrollKey={`direct_timeline-${columnId}`} + timelineId='direct' + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />} + /> + ); + } else { + contents = ( + <StatusListContainer + trackScroll={!pinned} + scrollKey={`direct_timeline-${columnId}`} + timelineId='direct' + onLoadMore={this.handleLoadMoreTimeline} + emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />} + /> + ); + } + return ( <Column ref={this.setRef} label={intl.formatMessage(messages.title)}> <ColumnHeader @@ -90,13 +149,28 @@ export default class DirectTimeline extends React.PureComponent { <ColumnSettingsContainer /> </ColumnHeader> - <StatusListContainer - trackScroll={!pinned} - scrollKey={`direct_timeline-${columnId}`} - timelineId='direct' - onLoadMore={this.handleLoadMore} - emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />} - /> + <div className='notification__filter-bar'> + <button + className={conversationsMode ? 'active' : ''} + onClick={this.handleConversationsClick} + > + <FormattedMessage + id='direct.conversations_mode' + defaultMessage='Conversations' + /> + </button> + <button + className={conversationsMode ? '' : 'active'} + onClick={this.handleTimelineClick} + > + <FormattedMessage + id='direct.timeline_mode' + defaultMessage='Timeline' + /> + </button> + </div> + + {contents} </Column> ); } diff --git a/app/javascript/flavours/glitch/reducers/conversations.js b/app/javascript/flavours/glitch/reducers/conversations.js new file mode 100644 index 000000000..c01659da5 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/conversations.js @@ -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; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index 45b93b92c..266d87dc1 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -28,6 +28,7 @@ import lists from './lists'; import listEditor from './list_editor'; import listAdder from './list_adder'; import filters from './filters'; +import conversations from './conversations'; import suggestions from './suggestions'; import pinnedAccountsEditor from './pinned_accounts_editor'; import polls from './polls'; @@ -64,6 +65,7 @@ const reducers = { listEditor, listAdder, filters, + conversations, suggestions, pinnedAccountsEditor, polls, diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index cc86a6b20..a37863a69 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -72,6 +72,7 @@ const initialState = ImmutableMap({ }), direct: ImmutableMap({ + conversations: true, regex: ImmutableMap({ body: '', }), diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index c0340e3f8..d2233207d 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -46,6 +46,18 @@ vertical-align: middle; margin-right: 5px; } + + &-composite { + @include avatar-radius; + overflow: hidden; + + & div { + @include avatar-radius; + float: left; + position: relative; + box-sizing: border-box; + } + } } .account__avatar-overlay { diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 63211392e..9db64bbcb 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -287,8 +287,12 @@ text-overflow: ellipsis; white-space: nowrap; + a { + color: inherit; + text-decoration: inherit; + } + strong { - display: block; height: 18px; font-size: 16px; font-weight: 500; @@ -308,7 +312,7 @@ white-space: nowrap; } - &:hover { + > a:hover { strong { text-decoration: underline; } diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 327694a7e..ee4440e89 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -209,7 +209,7 @@ outline: 0; background: lighten($ui-base-color, 4%); - .status.status-direct { + &.status.status-direct:not(.read) { background: lighten($ui-base-color, 12%); &.muted { @@ -249,8 +249,9 @@ margin-top: 8px; } - &.status-direct { + &.status-direct:not(.read) { background: lighten($ui-base-color, 8%); + border-bottom-color: lighten($ui-base-color, 12%); } &.light { @@ -333,7 +334,7 @@ &:focus > .status__content:after { background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1)); } - &.status-direct> .status__content:after { + &.status-direct:not(.read)> .status__content:after { background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1)); } @@ -599,7 +600,7 @@ } } -.status__display-name, +a.status__display-name, .reply-indicator__display-name, .detailed-status__display-name, .account__display-name { diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index ce2a2eeb5..3e4a15c9f 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -27,15 +27,16 @@ } } -.status.status-direct { +.status.status-direct:not(.read) { background: darken($ui-base-color, 8%); + border-bottom-color: darken($ui-base-color, 12%); &.collapsed> .status__content:after { background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1)); } } -.focusable:focus.status.status-direct { +.focusable:focus.status.status-direct:not(.read) { background: darken($ui-base-color, 4%); &.collapsed> .status__content:after { |