Update action bar to incorporate sending states

This moves most of them out of the context menu.
This commit is contained in:
Travis Ralston 2021-04-21 16:16:05 -06:00
parent 91b3688feb
commit c5dd6b4dfb
7 changed files with 128 additions and 126 deletions

View file

@ -105,3 +105,11 @@ limitations under the License.
.mx_MessageActionBar_optionsButton::after { .mx_MessageActionBar_optionsButton::after {
mask-image: url('$(res)/img/element-icons/context-menu.svg'); mask-image: url('$(res)/img/element-icons/context-menu.svg');
} }
.mx_MessageActionBar_resendButton::after {
mask-image: url('$(res)/img/element-icons/retry.svg');
}
.mx_MessageActionBar_cancelButton::after {
mask-image: url('$(res)/img/element-icons/trashcan.svg');
}

View file

@ -214,10 +214,6 @@ $left-gutter: 64px;
color: $accent-fg-color; color: $accent-fg-color;
} }
.mx_EventTile_notSent {
color: $event-notsent-color;
}
.mx_EventTile_receiptSent, .mx_EventTile_receiptSent,
.mx_EventTile_receiptSending { .mx_EventTile_receiptSending {
// We don't use `position: relative` on the element because then it won't line // We don't use `position: relative` on the element because then it won't line

View file

@ -1,8 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -34,7 +32,7 @@ import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event"; import {EventType} from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
function canCancel(eventStatus) { export function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
} }
@ -98,21 +96,6 @@ export default class MessageContextMenu extends React.Component {
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
} }
onResendClick = () => {
Resend.resend(this.props.mxEvent);
this.closeMenu();
};
onResendEditClick = () => {
Resend.resend(this.props.mxEvent.replacingEvent());
this.closeMenu();
};
onResendRedactionClick = () => {
Resend.resend(this.props.mxEvent.localRedactionEvent());
this.closeMenu();
};
onResendReactionsClick = () => { onResendReactionsClick = () => {
for (const reaction of this._getUnsentReactions()) { for (const reaction of this._getUnsentReactions()) {
Resend.resend(reaction); Resend.resend(reaction);
@ -170,29 +153,6 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu(); this.closeMenu();
}; };
onCancelSendClick = () => {
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const pendingReactions = this._getPendingReactions();
if (editEvent && canCancel(editEvent.status)) {
Resend.removeFromQueue(editEvent);
}
if (redactEvent && canCancel(redactEvent.status)) {
Resend.removeFromQueue(redactEvent);
}
if (pendingReactions.length) {
for (const reaction of pendingReactions) {
Resend.removeFromQueue(reaction);
}
}
if (canCancel(mxEvent.status)) {
Resend.removeFromQueue(this.props.mxEvent);
}
this.closeMenu();
};
onForwardClick = () => { onForwardClick = () => {
if (this.props.onCloseDialog) this.props.onCloseDialog(); if (this.props.onCloseDialog) this.props.onCloseDialog();
dis.dispatch({ dis.dispatch({
@ -285,20 +245,9 @@ export default class MessageContextMenu extends React.Component {
const me = cli.getUserId(); const me = cli.getUserId();
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status; const eventStatus = mxEvent.status;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const unsentReactionsCount = this._getUnsentReactions().length; const unsentReactionsCount = this._getUnsentReactions().length;
const pendingReactionsCount = this._getPendingReactions().length;
const allowCancel = canCancel(mxEvent.status) ||
canCancel(editStatus) ||
canCancel(redactStatus) ||
pendingReactionsCount !== 0;
let resendButton;
let resendEditButton;
let resendReactionsButton; let resendReactionsButton;
let resendRedactionButton;
let redactButton; let redactButton;
let cancelButton;
let forwardButton; let forwardButton;
let pinButton; let pinButton;
let unhidePreviewButton; let unhidePreviewButton;
@ -309,22 +258,6 @@ export default class MessageContextMenu extends React.Component {
// status is SENT before remote-echo, null after // status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT; const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) { if (!mxEvent.isRedacted()) {
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
</MenuItem>
);
}
if (editStatus === EventStatus.NOT_SENT) {
resendEditButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendEditClick}>
{ _t('Resend edit') }
</MenuItem>
);
}
if (unsentReactionsCount !== 0) { if (unsentReactionsCount !== 0) {
resendReactionsButton = ( resendReactionsButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}>
@ -334,14 +267,6 @@ export default class MessageContextMenu extends React.Component {
} }
} }
if (redactStatus === EventStatus.NOT_SENT) {
resendRedactionButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendRedactionClick}>
{ _t('Resend removal') }
</MenuItem>
);
}
if (isSent && this.state.canRedact) { if (isSent && this.state.canRedact) {
redactButton = ( redactButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
@ -350,14 +275,6 @@ export default class MessageContextMenu extends React.Component {
); );
} }
if (allowCancel) {
cancelButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') }
</MenuItem>
);
}
if (isContentActionable(mxEvent)) { if (isContentActionable(mxEvent)) {
forwardButton = ( forwardButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}> <MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
@ -455,12 +372,8 @@ export default class MessageContextMenu extends React.Component {
return ( return (
<div className="mx_MessageContextMenu"> <div className="mx_MessageContextMenu">
{ resendButton }
{ resendEditButton }
{ resendReactionsButton } { resendReactionsButton }
{ resendRedactionButton }
{ redactButton } { redactButton }
{ cancelButton }
{ forwardButton } { forwardButton }
{ pinButton } { pinButton }
{ viewSourceButton } { viewSourceButton }

View file

@ -160,7 +160,6 @@ export default class EditHistoryMessage extends React.PureComponent {
"mx_EventTile": true, "mx_EventTile": true,
// Note: we keep the `sending` state class for tests, not for our styles // Note: we keep the `sending` state class for tests, not for our styles
"mx_EventTile_sending": isSending, "mx_EventTile_sending": isSending,
"mx_EventTile_notSent": this.state.sendStatus === 'not_sent',
}); });
return ( return (
<li> <li>

View file

@ -29,6 +29,8 @@ import RoomContext from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar"; import Toolbar from "../../../accessibility/Toolbar";
import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex"; import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {canCancel} from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -169,45 +171,118 @@ export default class MessageActionBar extends React.PureComponent {
}); });
}; };
render() { /**
let reactButton; * Runs a given fn on the set of possible events to test. The first event
let replyButton; * that passes the checkFn will have fn executed on it. Both functions take
let editButton; * a MatrixEvent object. If no particular conditions are needed, checkFn can
* be null/undefined. If no functions pass the checkFn, no action will be
* taken.
* @param {Function} fn The execution function.
* @param {Function} checkFn The test function.
*/
runActionOnFailedEv(fn, checkFn) {
if (!checkFn) checkFn = () => true;
if (isContentActionable(this.props.mxEvent)) { const mxEvent = this.props.mxEvent;
if (this.context.canReact) { const editEvent = mxEvent.replacingEvent();
reactButton = ( const redactEvent = mxEvent.localRedactionEvent();
<ReactButton mxEvent={this.props.mxEvent} reactions={this.props.reactions} onFocusChange={this.onFocusChange} /> const tryOrder = [redactEvent, editEvent, mxEvent];
for (const ev of tryOrder) {
if (ev && checkFn(ev)) {
fn(ev);
break;
}
}
}
onResendClick = (ev) => {
this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv));
};
onCancelClick = (ev) => {
this.runActionOnFailedEv(
(tarEv) => Resend.removeFromQueue(tarEv),
(testEv) => canCancel(testEv.status),
); );
} };
if (this.context.canReply) {
replyButton = <RovingAccessibleTooltipButton render() {
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton" const toolbarOpts = [];
title={_t("Reply")}
onClick={this.onReplyClick}
/>;
}
}
if (canEditContent(this.props.mxEvent)) { if (canEditContent(this.props.mxEvent)) {
editButton = <RovingAccessibleTooltipButton toolbarOpts.push(<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
title={_t("Edit")} title={_t("Edit")}
onClick={this.onEditClick} onClick={this.onEditClick}
/>; key="edit"
/>);
} }
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive. const cancelSendingButton = <RovingAccessibleTooltipButton
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off"> className="mx_MessageActionBar_maskButton mx_MessageActionBar_cancelButton"
{reactButton} title={_t("Delete")}
{replyButton} onClick={this.onCancelClick}
{editButton} key="cancel"
<OptionsButton />;
// We show a different toolbar for failed events, so detect that first.
const mxEvent = this.props.mxEvent;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus);
const isFailed = [mxEvent.status, editStatus, redactStatus].includes("not_sent");
if (allowCancel && isFailed) {
// The resend button needs to appear ahead of the edit button, so insert to the
// start of the opts
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_resendButton"
title={_t("Retry")}
onClick={this.onResendClick}
key="resend"
/>);
// The delete button should appear last, so we can just drop it at the end
toolbarOpts.push(cancelSendingButton);
} else {
if (isContentActionable(this.props.mxEvent)) {
// Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) {
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>);
}
if (this.context.canReact) {
toolbarOpts.splice(0, 0, <ReactButton
mxEvent={this.props.mxEvent}
reactions={this.props.reactions}
onFocusChange={this.onFocusChange}
key="react"
/>);
}
}
if (allowCancel) {
toolbarOpts.push(cancelSendingButton);
}
// The menu button should be last, so dump it there.
toolbarOpts.push(<OptionsButton
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
getReplyThread={this.props.getReplyThread} getReplyThread={this.props.getReplyThread}
getTile={this.props.getTile} getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange} onFocusChange={this.onFocusChange}
/> key="menu"
/>);
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
{toolbarOpts}
</Toolbar>; </Toolbar>;
} }
} }

View file

@ -40,6 +40,9 @@ import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStor
import {objectHasDiff} from "../../../utils/objects"; import {objectHasDiff} from "../../../utils/objects";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip"; import Tooltip from "../elements/Tooltip";
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import {NotificationColor} from "../../../stores/notifications/NotificationColor";
import NotificationBadge from "./NotificationBadge";
const eventTileTypes = { const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent', [EventType.RoomMessage]: 'messages.MessageEvent',
@ -838,7 +841,6 @@ export default class EventTile extends React.Component {
mx_EventTile_12hr: this.props.isTwelveHour, mx_EventTile_12hr: this.props.isTwelveHour,
// Note: we keep the `sending` state class for tests, not for our styles // Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending, mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(), mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent, mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation, mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
@ -1253,11 +1255,19 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
render() { render() {
const isSent = !this.props.messageState || this.props.messageState === 'sent'; const isSent = !this.props.messageState || this.props.messageState === 'sent';
const isFailed = this.props.messageState === 'not_sent';
const receiptClasses = classNames({ const receiptClasses = classNames({
'mx_EventTile_receiptSent': isSent, 'mx_EventTile_receiptSent': isSent,
'mx_EventTile_receiptSending': !isSent, 'mx_EventTile_receiptSending': !isSent && !isFailed,
}); });
let nonCssBadge = null;
if (isFailed) {
nonCssBadge = <NotificationBadge
notification={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
/>;
}
let tooltip = null; let tooltip = null;
if (this.state.hover) { if (this.state.hover) {
let label = _t("Sending your message..."); let label = _t("Sending your message...");
@ -1265,6 +1275,8 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
label = _t("Encrypting your message..."); label = _t("Encrypting your message...");
} else if (isSent) { } else if (isSent) {
label = _t("Your message was sent"); label = _t("Your message was sent");
} else if (isFailed) {
label = _t("Failed to send");
} }
// The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated // The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated
// with the read receipt. // with the read receipt.
@ -1273,6 +1285,7 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
return <span className="mx_EventTile_readAvatars"> return <span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}> <span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{nonCssBadge}
{tooltip} {tooltip}
</span> </span>
</span>; </span>;

View file

@ -1452,6 +1452,7 @@
"Sending your message...": "Sending your message...", "Sending your message...": "Sending your message...",
"Encrypting your message...": "Encrypting your message...", "Encrypting your message...": "Encrypting your message...",
"Your message was sent": "Your message was sent", "Your message was sent": "Your message was sent",
"Failed to send": "Failed to send",
"Please select the destination room for this message": "Please select the destination room for this message", "Please select the destination room for this message": "Please select the destination room for this message",
"Scroll to most recent messages": "Scroll to most recent messages", "Scroll to most recent messages": "Scroll to most recent messages",
"Close preview": "Close preview", "Close preview": "Close preview",
@ -1810,8 +1811,9 @@
"The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.",
"Error decrypting audio": "Error decrypting audio", "Error decrypting audio": "Error decrypting audio",
"React": "React", "React": "React",
"Reply": "Reply",
"Edit": "Edit", "Edit": "Edit",
"Retry": "Retry",
"Reply": "Reply",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Attachment": "Attachment", "Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",
@ -2390,7 +2392,6 @@
"Confirm encryption setup": "Confirm encryption setup", "Confirm encryption setup": "Confirm encryption setup",
"Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.", "Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.",
"Unable to set up keys": "Unable to set up keys", "Unable to set up keys": "Unable to set up keys",
"Retry": "Retry",
"Restoring keys from backup": "Restoring keys from backup", "Restoring keys from backup": "Restoring keys from backup",
"Fetching keys from server...": "Fetching keys from server...", "Fetching keys from server...": "Fetching keys from server...",
"%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored", "%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored",
@ -2419,10 +2420,7 @@
"Reject invitation": "Reject invitation", "Reject invitation": "Reject invitation",
"Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?",
"Unable to reject invite": "Unable to reject invite", "Unable to reject invite": "Unable to reject invite",
"Resend edit": "Resend edit",
"Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)",
"Resend removal": "Resend removal",
"Cancel Sending": "Cancel Sending",
"Forward Message": "Forward Message", "Forward Message": "Forward Message",
"Pin Message": "Pin Message", "Pin Message": "Pin Message",
"Unhide Preview": "Unhide Preview", "Unhide Preview": "Unhide Preview",