diff --git a/app/javascript/flavours/glitch/actions/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js index 856f8f10fe..e5c85c65d9 100644 --- a/app/javascript/flavours/glitch/actions/conversations.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js index c52df043a5..125b51c449 100644 --- a/app/javascript/flavours/glitch/components/avatar_composite.js +++ b/app/javascript/flavours/glitch/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'; } } @@ -96,7 +96,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/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 647ddf2764..4c3555dea4 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -80,6 +80,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ onReply (status, router) { dispatch((_, getState) => { let state = getState(); + if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.replyMessage), diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js index 9ddeabe752..17487b2021 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/containers/status_container'; +import StatusContent from 'flavours/glitch/components/status_content'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import AvatarComposite from 'flavours/glitch/components/avatar_composite'; +import Permalink from 'flavours/glitch/components/permalink'; +import IconButton from 'flavours/glitch/components/icon_button'; +import RelativeTimestamp from 'flavours/glitch/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,25 +32,61 @@ 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, }; + state = { + isExpanded: undefined, + }; + + parseClick = (e, destination) => { + const { router } = this.context; + const { lastStatus, unread, markRead } = this.props; + if (!router) return; + + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { + if (destination === undefined) { + if (unread) { + markRead(); + } + destination = `/statuses/${lastStatus.get('id')}`; + } + let state = {...router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + router.history.push(destination, state); + e.preventDefault(); + } + } + handleClick = () => { if (!this.context.router) { 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 +97,94 @@ 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 = () => { + if (this.props.lastStatus.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + setExpansion = value => { + this.setState({ isExpanded: value }); + } + + render () { + const { accounts, lastStatus, unread, intl } = this.props; + const { isExpanded } = this.state; + + 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, + }; + + let media = null; + if (lastStatus.get('media_attachments').size > 0) { + media = ; + } + return ( - + +
+
+ +
+ +
+
+
+ +
+ +
+ {names} }} /> +
+
+ + + +
+ + +
+ +
+
+
+
+
); } 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 index bd6f6bfb01..b15ce9f0f6 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/conversations'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { replyCompose } from 'flavours/glitch/actions/compose'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'flavours/glitch/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/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index b5a07239fd..5be4da48a8 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -50,6 +50,8 @@ &-composite { @include avatar-radius; overflow: hidden; + position: relative; + cursor: default; & div { @include avatar-radius; @@ -57,6 +59,18 @@ 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; + } } } diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 97c5255659..8ebcde5efd 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -1433,50 +1433,59 @@ height: 1em; } -.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; - } } ::-webkit-scrollbar-thumb {