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_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 = () => ({
|
export const mountConversations = () => ({
|
||||||
type: CONVERSATIONS_MOUNT,
|
type: CONVERSATIONS_MOUNT,
|
||||||
});
|
});
|
||||||
|
@ -82,3 +86,27 @@ export const updateConversations = conversation => dispatch => {
|
||||||
conversation,
|
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 (size === 2) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
right = '2px';
|
right = '1px';
|
||||||
} else {
|
} else {
|
||||||
left = '2px';
|
left = '1px';
|
||||||
}
|
}
|
||||||
} else if (size === 3) {
|
} else if (size === 3) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
right = '2px';
|
right = '1px';
|
||||||
} else if (index > 0) {
|
} else if (index > 0) {
|
||||||
left = '2px';
|
left = '1px';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === 1) {
|
if (index === 1) {
|
||||||
bottom = '2px';
|
bottom = '1px';
|
||||||
} else if (index > 1) {
|
} else if (index > 1) {
|
||||||
top = '2px';
|
top = '1px';
|
||||||
}
|
}
|
||||||
} else if (size === 4) {
|
} else if (size === 4) {
|
||||||
if (index === 0 || index === 2) {
|
if (index === 0 || index === 2) {
|
||||||
right = '2px';
|
right = '1px';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === 1 || index === 3) {
|
if (index === 1 || index === 3) {
|
||||||
left = '2px';
|
left = '1px';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index < 2) {
|
if (index < 2) {
|
||||||
bottom = '2px';
|
bottom = '1px';
|
||||||
} else {
|
} else {
|
||||||
top = '2px';
|
top = '1px';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,7 +88,13 @@ export default class AvatarComposite extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
onReply (status, router) {
|
onReply (status, router) {
|
||||||
dispatch((_, getState) => {
|
dispatch((_, getState) => {
|
||||||
let state = getState();
|
let state = getState();
|
||||||
|
|
||||||
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
message: intl.formatMessage(messages.replyMessage),
|
message: intl.formatMessage(messages.replyMessage),
|
||||||
|
|
|
@ -2,9 +2,28 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
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 = {
|
static contextTypes = {
|
||||||
router: PropTypes.object,
|
router: PropTypes.object,
|
||||||
|
@ -13,11 +32,12 @@ export default class Conversation extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
conversationId: PropTypes.string.isRequired,
|
conversationId: PropTypes.string.isRequired,
|
||||||
accounts: ImmutablePropTypes.list.isRequired,
|
accounts: ImmutablePropTypes.list.isRequired,
|
||||||
lastStatusId: PropTypes.string,
|
lastStatus: ImmutablePropTypes.map,
|
||||||
unread:PropTypes.bool.isRequired,
|
unread:PropTypes.bool.isRequired,
|
||||||
onMoveUp: PropTypes.func,
|
onMoveUp: PropTypes.func,
|
||||||
onMoveDown: PropTypes.func,
|
onMoveDown: PropTypes.func,
|
||||||
markRead: PropTypes.func.isRequired,
|
markRead: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClick = () => {
|
handleClick = () => {
|
||||||
|
@ -25,13 +45,25 @@ export default class Conversation extends ImmutablePureComponent {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lastStatusId, unread, markRead } = this.props;
|
const { lastStatus, unread, markRead } = this.props;
|
||||||
|
|
||||||
if (unread) {
|
if (unread) {
|
||||||
markRead();
|
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 = () => {
|
handleHotkeyMoveUp = () => {
|
||||||
|
@ -42,22 +74,88 @@ export default class Conversation extends ImmutablePureComponent {
|
||||||
this.props.onMoveDown(this.props.conversationId);
|
this.props.onMoveDown(this.props.conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
handleConversationMute = () => {
|
||||||
const { accounts, lastStatusId, unread } = this.props;
|
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;
|
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 (
|
return (
|
||||||
<StatusContainer
|
<HotKeys handlers={handlers}>
|
||||||
id={lastStatusId}
|
<div className='conversation focusable muted' tabIndex='0'>
|
||||||
unread={unread}
|
<div className='conversation__avatar'>
|
||||||
otherAccounts={accounts}
|
<AvatarComposite accounts={accounts} size={48} />
|
||||||
onMoveUp={this.handleHotkeyMoveUp}
|
</div>
|
||||||
onMoveDown={this.handleHotkeyMoveDown}
|
|
||||||
|
<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}
|
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 { connect } from 'react-redux';
|
||||||
import Conversation from '../components/conversation';
|
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 conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
|
||||||
|
const lastStatusId = conversation.get('last_status', null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
|
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
|
||||||
unread: conversation.get('unread'),
|
unread: conversation.get('unread'),
|
||||||
lastStatusId: conversation.get('last_status', null),
|
lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { conversationId }) => ({
|
const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
|
||||||
markRead: () => dispatch(markConversationRead(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 {
|
&-composite {
|
||||||
@include avatar-radius;
|
@include avatar-radius;
|
||||||
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
& > div {
|
& > div {
|
||||||
@include avatar-radius;
|
|
||||||
float: left;
|
float: left;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
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;
|
display: flex;
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
button {
|
&:focus {
|
||||||
box-sizing: border-box;
|
background: lighten($ui-base-color, 2%);
|
||||||
flex: 0 0 50%;
|
outline: 0;
|
||||||
background: transparent;
|
}
|
||||||
padding: 5px;
|
|
||||||
border: 0;
|
&__avatar {
|
||||||
position: relative;
|
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,
|
&:hover,
|
||||||
&:focus,
|
&:focus,
|
||||||
&:active {
|
&:active {
|
||||||
svg path:first-child {
|
text-decoration: underline;
|
||||||
fill: lighten($ui-base-color, 16%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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