| @@ -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, | ||||
| }); | ||||
|   | ||||
| @@ -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 ( | ||||
|       <div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}> | ||||
|         {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 && ( | ||||
|           <span className='account__avatar-composite__label'> | ||||
|             +{accounts.size - 4} | ||||
|           </span> | ||||
|         )} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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), | ||||
|   | ||||
| @@ -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 => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]); | ||||
|  | ||||
|     const handlers = { | ||||
|       reply: this.handleReply, | ||||
|       open: this.handleClick, | ||||
|       moveUp: this.handleHotkeyMoveUp, | ||||
|       moveDown: this.handleHotkeyMoveDown, | ||||
|       toggleHidden: this.handleShowMore, | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|       <StatusContainer | ||||
|         id={lastStatusId} | ||||
|         unread={unread} | ||||
|         otherAccounts={accounts} | ||||
|         onMoveUp={this.handleHotkeyMoveUp} | ||||
|         onMoveDown={this.handleHotkeyMoveDown} | ||||
|         onClick={this.handleClick} | ||||
|       /> | ||||
|       <HotKeys handlers={handlers}> | ||||
|         <div className='conversation focusable muted' tabIndex='0'> | ||||
|           <div className='conversation__avatar'> | ||||
|             <AvatarComposite accounts={accounts} size={48} /> | ||||
|           </div> | ||||
|  | ||||
|           <div className='conversation__content'> | ||||
|             <div className='conversation__content__info'> | ||||
|               <div className='conversation__content__relative-time'> | ||||
|                 <RelativeTimestamp timestamp={lastStatus.get('created_at')} /> | ||||
|               </div> | ||||
|  | ||||
|               <div className='conversation__content__names'> | ||||
|                 <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} /> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <StatusContent | ||||
|               status={lastStatus} | ||||
|               onClick={this.handleClick} | ||||
|               expanded={!lastStatus.get('hidden')} | ||||
|               onExpandedToggle={this.handleShowMore} | ||||
|               collapsable | ||||
|             /> | ||||
|  | ||||
|             {lastStatus.get('media_attachments').size > 0 && ( | ||||
|               <AttachmentList | ||||
|                 compact | ||||
|                 media={lastStatus.get('media_attachments')} | ||||
|               /> | ||||
|             )} | ||||
|  | ||||
|             <div className='status__action-bar'> | ||||
|               <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} /> | ||||
|  | ||||
|               <div className='status__action-bar-dropdown'> | ||||
|                 <DropdownMenuContainer status={lastStatus} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} /> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </HotKeys> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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)); | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user