Change conversations UI (#11896)
Fix #11414, fix #9860, fix #10434remotes/1727458204337373841/tmp_refs/heads/signup-info-prompt
parent
33b2e0f189
commit
bc5678d015
|
@ -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}
|
||||
<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 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?' },
|
||||
});
|
||||
|
||||
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'),
|
||||
lastStatusId: conversation.get('last_status', null),
|
||||
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;
|
||||
}
|
||||
|
||||
&__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 {
|
||||
svg path:first-child {
|
||||
fill: lighten($ui-base-color, 16%);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue