diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js
index c6e062ef73..4ef654b1f9 100644
--- a/app/javascript/mastodon/actions/conversations.js
+++ b/app/javascript/mastodon/actions/conversations.js
@@ -15,6 +15,10 @@ export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE';
export const CONVERSATIONS_READ = 'CONVERSATIONS_READ';
+export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST';
+export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS';
+export const CONVERSATIONS_DELETE_FAIL = 'CONVERSATIONS_DELETE_FAIL';
+
export const mountConversations = () => ({
type: CONVERSATIONS_MOUNT,
});
@@ -82,3 +86,27 @@ export const updateConversations = conversation => dispatch => {
conversation,
});
};
+
+export const deleteConversation = conversationId => (dispatch, getState) => {
+ dispatch(deleteConversationRequest(conversationId));
+
+ api(getState).delete(`/api/v1/conversations/${conversationId}`)
+ .then(() => dispatch(deleteConversationSuccess(conversationId)))
+ .catch(error => dispatch(deleteConversationFail(conversationId, error)));
+};
+
+export const deleteConversationRequest = id => ({
+ type: CONVERSATIONS_DELETE_REQUEST,
+ id,
+});
+
+export const deleteConversationSuccess = id => ({
+ type: CONVERSATIONS_DELETE_SUCCESS,
+ id,
+});
+
+export const deleteConversationFail = (id, error) => ({
+ type: CONVERSATIONS_DELETE_FAIL,
+ id,
+ error,
+});
diff --git a/app/javascript/mastodon/components/avatar_composite.js b/app/javascript/mastodon/components/avatar_composite.js
index 4a9a73c512..5d5b897492 100644
--- a/app/javascript/mastodon/components/avatar_composite.js
+++ b/app/javascript/mastodon/components/avatar_composite.js
@@ -35,35 +35,35 @@ export default class AvatarComposite extends React.PureComponent {
if (size === 2) {
if (index === 0) {
- right = '2px';
+ right = '1px';
} else {
- left = '2px';
+ left = '1px';
}
} else if (size === 3) {
if (index === 0) {
- right = '2px';
+ right = '1px';
} else if (index > 0) {
- left = '2px';
+ left = '1px';
}
if (index === 1) {
- bottom = '2px';
+ bottom = '1px';
} else if (index > 1) {
- top = '2px';
+ top = '1px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
- right = '2px';
+ right = '1px';
}
if (index === 1 || index === 3) {
- left = '2px';
+ left = '1px';
}
if (index < 2) {
- bottom = '2px';
+ bottom = '1px';
} else {
- top = '2px';
+ top = '1px';
}
}
@@ -88,7 +88,13 @@ export default class AvatarComposite extends React.PureComponent {
return (
- {accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
+ {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))}
+
+ {accounts.size > 4 && (
+
+ +{accounts.size - 4}
+
+ )}
);
}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index fa58589a61..7b0906b396 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -56,6 +56,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) {
dispatch((_, getState) => {
let state = getState();
+
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
index ffcd6d2811..cc3faf0de6 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.js
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
@@ -2,9 +2,28 @@ 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 '../../../containers/status_container';
+import StatusContent from 'mastodon/components/status_content';
+import AttachmentList from 'mastodon/components/attachment_list';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+import AvatarComposite from 'mastodon/components/avatar_composite';
+import Permalink from 'mastodon/components/permalink';
+import IconButton from 'mastodon/components/icon_button';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import { HotKeys } from 'react-hotkeys';
-export default class Conversation extends ImmutablePureComponent {
+const messages = defineMessages({
+ more: { id: 'status.more', defaultMessage: 'More' },
+ open: { id: 'conversation.open', defaultMessage: 'View conversation' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' },
+ delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+});
+
+export default @injectIntl
+class Conversation extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@@ -13,11 +32,12 @@ export default class Conversation extends ImmutablePureComponent {
static propTypes = {
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired,
- lastStatusId: PropTypes.string,
+ lastStatus: ImmutablePropTypes.map,
unread:PropTypes.bool.isRequired,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
markRead: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
};
handleClick = () => {
@@ -25,13 +45,25 @@ export default class Conversation extends ImmutablePureComponent {
return;
}
- const { lastStatusId, unread, markRead } = this.props;
+ const { lastStatus, unread, markRead } = this.props;
if (unread) {
markRead();
}
- this.context.router.history.push(`/statuses/${lastStatusId}`);
+ this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
+ }
+
+ handleMarkAsRead = () => {
+ this.props.markRead();
+ }
+
+ handleReply = () => {
+ this.props.reply(this.props.lastStatus, this.context.router.history);
+ }
+
+ handleDelete = () => {
+ this.props.delete();
}
handleHotkeyMoveUp = () => {
@@ -42,22 +74,88 @@ export default class Conversation extends ImmutablePureComponent {
this.props.onMoveDown(this.props.conversationId);
}
- render () {
- const { accounts, lastStatusId, unread } = this.props;
+ handleConversationMute = () => {
+ this.props.onMute(this.props.lastStatus);
+ }
- if (lastStatusId === null) {
+ handleShowMore = () => {
+ this.props.onToggleHidden(this.props.lastStatus);
+ }
+
+ render () {
+ const { accounts, lastStatus, unread, intl } = this.props;
+
+ if (lastStatus === null) {
return null;
}
+ const menu = [
+ { text: intl.formatMessage(messages.open), action: this.handleClick },
+ null,
+ ];
+
+ menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
+
+ if (unread) {
+ menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
+ menu.push(null);
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
+
+ const names = accounts.map(a => ).reduce((prev, cur) => [prev, ', ', cur]);
+
+ const handlers = {
+ reply: this.handleReply,
+ open: this.handleClick,
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ toggleHidden: this.handleShowMore,
+ };
+
return (
-
+
+
+
+
+
+
+
+
+
+
+
+ {names} }} />
+
+
+
+
+
+ {lastStatus.get('media_attachments').size > 0 && (
+
+ )}
+
+
+
+
+
);
}
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
index bd6f6bfb01..94cef81a7d 100644
--- a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
+++ b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
@@ -1,19 +1,74 @@
import { connect } from 'react-redux';
import Conversation from '../components/conversation';
-import { markConversationRead } from '../../../actions/conversations';
+import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
+import { makeGetStatus } from 'mastodon/selectors';
+import { replyCompose } from 'mastodon/actions/compose';
+import { openModal } from 'mastodon/actions/modal';
+import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/actions/statuses';
+import { defineMessages, injectIntl } from 'react-intl';
-const mapStateToProps = (state, { conversationId }) => {
- const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
+const messages = defineMessages({
+ replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
- return {
- accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
- unread: conversation.get('unread'),
- lastStatusId: conversation.get('last_status', null),
+const mapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ return (state, { conversationId }) => {
+ const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
+ const lastStatusId = conversation.get('last_status', null);
+
+ return {
+ accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
+ unread: conversation.get('unread'),
+ lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
+ };
};
};
-const mapDispatchToProps = (dispatch, { conversationId }) => ({
- markRead: () => dispatch(markConversationRead(conversationId)),
+const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
+
+ markRead () {
+ dispatch(markConversationRead(conversationId));
+ },
+
+ reply (status, router) {
+ dispatch((_, getState) => {
+ let state = getState();
+
+ if (state.getIn(['compose', 'text']).trim().length !== 0) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.replyMessage),
+ confirm: intl.formatMessage(messages.replyConfirm),
+ onConfirm: () => dispatch(replyCompose(status, router)),
+ }));
+ } else {
+ dispatch(replyCompose(status, router));
+ }
+ });
+ },
+
+ delete () {
+ dispatch(deleteConversation(conversationId));
+ },
+
+ onMute (status) {
+ if (status.get('muted')) {
+ dispatch(unmuteStatus(status.get('id')));
+ } else {
+ dispatch(muteStatus(status.get('id')));
+ }
+ },
+
+ onToggleHidden (status) {
+ if (status.get('hidden')) {
+ dispatch(revealStatus(status.get('id')));
+ } else {
+ dispatch(hideStatus(status.get('id')));
+ }
+ },
+
});
-export default connect(mapStateToProps, mapDispatchToProps)(Conversation);
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 17c94e23cd..f4f26203e5 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1276,14 +1276,28 @@
&-composite {
@include avatar-radius;
+ border-radius: 50%;
overflow: hidden;
+ position: relative;
+ cursor: default;
& > div {
- @include avatar-radius;
float: left;
position: relative;
box-sizing: border-box;
}
+
+ &__label {
+ display: block;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: $primary-text-color;
+ text-shadow: 1px 1px 2px $base-shadow-color;
+ font-weight: 700;
+ font-size: 15px;
+ }
}
}
@@ -6383,48 +6397,57 @@ noscript {
}
}
-.layout-toggle {
+.conversation {
display: flex;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
padding: 5px;
+ padding-bottom: 0;
- button {
- box-sizing: border-box;
- flex: 0 0 50%;
- background: transparent;
- padding: 5px;
- border: 0;
- position: relative;
+ &:focus {
+ background: lighten($ui-base-color, 2%);
+ outline: 0;
+ }
- &:hover,
- &:focus,
- &:active {
- svg path:first-child {
- fill: lighten($ui-base-color, 16%);
+ &__avatar {
+ flex: 0 0 auto;
+ padding: 10px;
+ padding-top: 12px;
+ }
+
+ &__content {
+ flex: 1 1 auto;
+ padding: 10px 5px;
+ padding-right: 15px;
+
+ &__info {
+ overflow: hidden;
+ }
+
+ &__relative-time {
+ float: right;
+ font-size: 15px;
+ color: $darker-text-color;
+ padding-left: 15px;
+ }
+
+ &__names {
+ color: $darker-text-color;
+ font-size: 15px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-bottom: 4px;
+
+ a {
+ color: $primary-text-color;
+ text-decoration: none;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: underline;
+ }
}
}
}
-
- svg {
- width: 100%;
- height: auto;
-
- path:first-child {
- fill: lighten($ui-base-color, 12%);
- }
-
- path:last-child {
- fill: darken($ui-base-color, 14%);
- }
- }
-
- &__active {
- color: $ui-highlight-color;
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: lighten($ui-base-color, 12%);
- border-radius: 50%;
- padding: 0.35rem;
- }
}