diff --git a/app/javascript/flavours/glitch/actions/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js
new file mode 100644
index 0000000000..856f8f10fe
--- /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 b5dd70989f..21379f4923 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 0000000000..c52df043a5
--- /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 (
+ this.props.onAccountClick(account.get('id'), e)}
+ title={`@${account.get('acct')}`}
+ key={account.get('id')}
+ >
+
+
+ );
+ }
+
+ render() {
+ const { accounts, size } = this.props;
+
+ return (
+
+ {accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index a26cff0492..7f6ef5a5da 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 => (
+ onAccountClick(a.get('id'), e)}
+ title={`@${a.get('acct')}`}
+ >
+
+
+
+
+ )).reduce((prev, cur) => [prev, ', ', cur]);
+
+ if (others.size - 2 > 0) {
+ displayName.push(` +${others.size - 2}`);
+ }
+
+ suffix = (
+ onAccountClick(account.get('id'), e)}>
+ @{acct}
+
+ );
+ } else {
+ displayName = ;
+ suffix = @{acct};
+ }
+
+ return (
-
+ {displayName}
{inline ? ' ' : null}
- @{acct}
+ {suffix}
- ) : 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 7014cab17c..4b9364ae54 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}
@@ -656,6 +667,7 @@ export default class Status extends ImmutablePureComponent {
collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isCollapsed}
setCollapsed={setCollapsed}
+ 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 4c398fd194..85bc4a9760 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 (
{replyButton}
-
-
- {shareButton}
-
-
-
-
-
+ {!directMessage && [
+
,
+
,
+ shareButton,
+
,
+
+
+
,
+ ]}
diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js
index f9321904ca..23cff286af 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 (
-
- );
+ let statusAvatar;
+ if (otherAccounts && otherAccounts.size > 0) {
+ statusAvatar = ;
+ } else if (friend === undefined || friend === null) {
+ statusAvatar = ;
+ } else {
+ statusAvatar = ;
+ }
+
+ if (!otherAccounts) {
+ return (
+
+ );
+ } else {
+ // This is a DM conversation
+ return (
+
+
+ {statusAvatar}
+
+
+
+
+
+
+ );
+ }
}
}
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index c9747650f2..4a2c628817 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}
- {(
-
- )}
+ {!directMessage && }
{collapsible ? (
{
+ 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 (
+
+ );
+ }
+
+}
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 0000000000..4fa76fd6df
--- /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 (
+
+ {conversations.map(item => (
+
+ ))}
+
+ );
+ }
+
+}
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 0000000000..bd6f6bfb01
--- /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 0000000000..e10558f3ab
--- /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 dc7e0534db..6fe8a1ce86 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 = (
+ }
+ />
+ );
+ } else {
+ contents = (
+ }
+ />
+ );
+ }
+
return (
- }
- />
+
+
+
+
+
+ {contents}
);
}
diff --git a/app/javascript/flavours/glitch/reducers/conversations.js b/app/javascript/flavours/glitch/reducers/conversations.js
new file mode 100644
index 0000000000..c01659da5e
--- /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 45b93b92c1..266d87dc1b 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 cc86a6b20d..a37863a697 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 c0340e3f83..d2233207d7 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 63211392e9..9db64bbcbd 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 327694a7ec..ee4440e890 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 ce2a2eeb50..3e4a15c9f3 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 {