From 91b3688feb160e4a309a02f06890645e1f56dda4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 21 Apr 2021 13:49:58 -0600 Subject: [PATCH 1/7] Redesign "failed to send messages" status bar --- res/css/structures/_RoomStatusBar.scss | 97 +++++++++++++- res/img/element-icons/retry.svg | 3 + res/img/element-icons/trashcan.svg | 3 + src/Resend.js | 10 +- src/components/structures/RoomStatusBar.js | 124 +++++++++--------- .../views/rooms/NotificationBadge.tsx | 2 +- src/i18n/strings/en_EN.json | 10 +- src/utils/ErrorUtils.js | 6 - 8 files changed, 177 insertions(+), 78 deletions(-) create mode 100644 res/img/element-icons/retry.svg create mode 100644 res/img/element-icons/trashcan.svg diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 5bf2aee3ae..db8e5a10ba 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomStatusBar { +.mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { margin-left: 65px; min-height: 50px; } @@ -68,6 +68,99 @@ limitations under the License. min-height: 58px; } +.mx_RoomStatusBar_unsentMessages { + > div[role="alert"] { + // cheat some basic alignment + display: flex; + align-items: center; + min-height: 70px; + margin: 12px; + padding-left: 16px; + background-color: $header-panel-bg-color; + border-radius: 4px; + } + + .mx_RoomStatusBar_unsentBadge { + margin-right: 12px; + + .mx_NotificationBadge { + // Override sizing from the default badge + width: 24px !important; + height: 24px !important; + border-radius: 24px !important; + + .mx_NotificationBadge_count { + font-size: $font-16px !important; // override default + } + } + } + + .mx_RoomStatusBar_unsentTitle { + color: $warning-color; + font-size: $font-15px; + } + + .mx_RoomStatusBar_unsentDescription { + font-size: $font-12px; + } + + .mx_RoomStatusBar_unsentButtonBar { + flex-grow: 1; + text-align: right; + margin-right: 22px; + color: $muted-fg-color; + + .mx_AccessibleButton { + padding: 5px 10px; + padding-left: 28px; // 16px for the icon, 2px margin to text, 10px regular padding + display: inline-block; + position: relative; + + &:nth-child(2) { + border-left: 1px solid $input-darker-bg-color; + } + + &::before { + content: ''; + position: absolute; + left: 10px; // inset for regular button padding + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + &.mx_RoomStatusBar_unsentCancelAllBtn::before { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); + width: 12px; + height: 16px; + top: calc(50% - 8px); // text sizes are dynamic + } + + &.mx_RoomStatusBar_unsentResendAllBtn { + padding-left: 34px; // 28px from above, but +6px to account for the wider icon + + &::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + top: calc(50% - 9px); // text sizes are dynamic + } + } + } + + .mx_InlineSpinner { + vertical-align: middle; + margin-right: 5px; + top: 1px; // just to help the vertical alignment be slightly better + + & + span { + margin-right: 10px; // same margin/padding as the rightmost button + } + } + } +} + .mx_RoomStatusBar_connectionLostBar img { padding-left: 10px; padding-right: 10px; @@ -103,7 +196,7 @@ limitations under the License. } .mx_MatrixChat_useCompactLayout { - .mx_RoomStatusBar { + .mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { min-height: 40px; } diff --git a/res/img/element-icons/retry.svg b/res/img/element-icons/retry.svg new file mode 100644 index 0000000000..09448d6458 --- /dev/null +++ b/res/img/element-icons/retry.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/trashcan.svg b/res/img/element-icons/trashcan.svg new file mode 100644 index 0000000000..f8fb8b5c46 --- /dev/null +++ b/res/img/element-icons/trashcan.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Resend.js b/src/Resend.js index bf69e59c1a..f1e5fb38f5 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -21,11 +21,11 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event'; export default class Resend { static resendUnsentEvents(room) { - room.getPendingEvents().filter(function(ev) { + return Promise.all(room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; - }).forEach(function(event) { - Resend.resend(event); - }); + }).map(function(event) { + return Resend.resend(event); + })); } static cancelUnsentEvents(room) { @@ -38,7 +38,7 @@ export default class Resend { static resend(event) { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent(event, room).then(function(res) { + return MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 54b6fee233..deffd6b95b 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -1,5 +1,5 @@ /* -Copyright 2015-2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,10 +20,15 @@ import { _t, _td } from '../../languageHandler'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; -import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; +import {messageForResourceLimitError} from '../../utils/ErrorUtils'; import {Action} from "../../dispatcher/actions"; import {replaceableComponent} from "../../utils/replaceableComponent"; import {EventStatus} from "matrix-js-sdk/src/models/event"; +import NotificationBadge from "../views/rooms/NotificationBadge"; +import {NotificationColor} from "../../stores/notifications/NotificationColor"; +import {StaticNotificationState} from "../../stores/notifications/StaticNotificationState"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import InlineSpinner from "../views/elements/InlineSpinner"; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -76,6 +81,7 @@ export default class RoomStatusBar extends React.Component { syncState: MatrixClientPeg.get().getSyncState(), syncStateData: MatrixClientPeg.get().getSyncStateData(), unsentMessages: getUnsentMessages(this.props.room), + isResending: false, }; componentDidMount() { @@ -109,7 +115,10 @@ export default class RoomStatusBar extends React.Component { }; _onResendAllClick = () => { - Resend.resendUnsentEvents(this.props.room); + Resend.resendUnsentEvents(this.props.room).then(() => { + this.setState({isResending: false}); + }); + this.setState({isResending: true}); dis.fire(Action.FocusComposer); }; @@ -120,10 +129,7 @@ export default class RoomStatusBar extends React.Component { _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { if (room.roomId !== this.props.room.roomId) return; - - this.setState({ - unsentMessages: getUnsentMessages(this.props.room), - }); + this.setState({unsentMessages: getUnsentMessages(this.props.room)}); }; // Check whether current size is greater than 0, if yes call props.onVisible @@ -141,7 +147,7 @@ export default class RoomStatusBar extends React.Component { _getSize() { if (this._shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; - } else if (this.state.unsentMessages.length > 0) { + } else if (this.state.unsentMessages.length > 0 || this.state.isResending) { return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; @@ -162,7 +168,6 @@ export default class RoomStatusBar extends React.Component { _getUnsentMessageContent() { const unsentMessages = this.state.unsentMessages; - if (!unsentMessages.length) return null; let title; @@ -206,75 +211,76 @@ export default class RoomStatusBar extends React.Component { "Please contact your service administrator to continue using the service.", ), }); - } else if ( - unsentMessages.length === 1 && - unsentMessages[0].error && - unsentMessages[0].error.data && - unsentMessages[0].error.data.error - ) { - title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error; } else { - title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); + title = _t('Some of your messages have not been sent'); } - const content = _t("%(count)s Resend all or cancel all " + - "now. You can also select individual messages to resend or cancel.", - { count: unsentMessages.length }, - { - 'resendText': (sub) => - { sub }, - 'cancelText': (sub) => - { sub }, - }, - ); + let buttonRow = <> + + {_t("Delete all")} + + + {_t("Retry all")} + + ; + if (this.state.isResending) { + buttonRow = <> + + {/* span for css */} + {_t("Sending")} + ; + } - return
- -
-
- { title } -
-
- { content } + return <> +
+
+
+ +
+
+
+ { title } +
+
+ { _t("You can select all or individual messages to retry or delete") } +
+
+
+ {buttonRow} +
-
; + ; } - // return suitable content for the main (text) part of the status bar. - _getContent() { + render() { if (this._shouldShowConnectionError()) { return ( -
- /!\ -
-
- { _t('Connectivity to the server has been lost.') } -
-
- { _t('Sent messages will be stored until your connection has returned.') } +
+
+
+ /!\ +
+
+ {_t('Connectivity to the server has been lost.')} +
+
+ {_t('Sent messages will be stored until your connection has returned.')} +
+
); } - if (this.state.unsentMessages.length > 0) { + if (this.state.unsentMessages.length > 0 || this.state.isResending) { return this._getUnsentMessageContent(); } return null; } - - render() { - const content = this._getContent(); - - return ( -
-
- { content } -
-
- ); - } } diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 36a52e260d..4b843bfc29 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -30,7 +30,7 @@ interface IProps { * If true, the badge will show a count if at all possible. This is typically * used to override the user's preference for things like room sublists. */ - forceCount: boolean; + forceCount?: boolean; /** * The room ID, if any, the badge represents. diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 133d24e3c8..0b525670a8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -658,7 +658,6 @@ "No homeserver URL provided": "No homeserver URL provided", "Unexpected error resolving homeserver configuration": "Unexpected error resolving homeserver configuration", "Unexpected error resolving identity server configuration": "Unexpected error resolving identity server configuration", - "The message you are trying to send is too large.": "The message you are trying to send is too large.", "This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.", "This homeserver has been blocked by its administrator.": "This homeserver has been blocked by its administrator.", "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", @@ -2611,10 +2610,11 @@ "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.", "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.", - "%(count)s of your messages have not been sent.|other": "Some of your messages have not been sent.", - "%(count)s of your messages have not been sent.|one": "Your message was not sent.", - "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|other": "Resend all or cancel all now. You can also select individual messages to resend or cancel.", - "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|one": "Resend message or cancel message now.", + "Some of your messages have not been sent": "Some of your messages have not been sent", + "Delete all": "Delete all", + "Retry all": "Retry all", + "Sending": "Sending", + "You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", diff --git a/src/utils/ErrorUtils.js b/src/utils/ErrorUtils.js index 2c6acd5503..b5bd5b0af0 100644 --- a/src/utils/ErrorUtils.js +++ b/src/utils/ErrorUtils.js @@ -49,12 +49,6 @@ export function messageForResourceLimitError(limitType, adminContact, strings, e } } -export function messageForSendError(errorData) { - if (errorData.errcode === "M_TOO_LARGE") { - return _t("The message you are trying to send is too large."); - } -} - export function messageForSyncError(err) { if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const limitError = messageForResourceLimitError( From c5dd6b4dfb95eab8f274875556a6e1d38a3a323d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 21 Apr 2021 16:16:05 -0600 Subject: [PATCH 2/7] Update action bar to incorporate sending states This moves most of them out of the context menu. --- res/css/views/messages/_MessageActionBar.scss | 8 ++ res/css/views/rooms/_EventTile.scss | 4 - .../views/context_menus/MessageContextMenu.js | 91 +------------ .../views/messages/EditHistoryMessage.js | 1 - .../views/messages/MessageActionBar.js | 125 ++++++++++++++---- src/components/views/rooms/EventTile.js | 17 ++- src/i18n/strings/en_EN.json | 8 +- 7 files changed, 128 insertions(+), 126 deletions(-) diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 1254b496b5..3ecbef0d1f 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -105,3 +105,11 @@ limitations under the License. .mx_MessageActionBar_optionsButton::after { 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'); +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 2b3e179c54..5d1dd04383 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -214,10 +214,6 @@ $left-gutter: 64px; color: $accent-fg-color; } -.mx_EventTile_notSent { - color: $event-notsent-color; -} - .mx_EventTile_receiptSent, .mx_EventTile_receiptSending { // We don't use `position: relative` on the element because then it won't line diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index f86cd26f32..142b8c80a8 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd 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"); 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 {replaceableComponent} from "../../../utils/replaceableComponent"; -function canCancel(eventStatus) { +export function canCancel(eventStatus) { 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()); } - 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 = () => { for (const reaction of this._getUnsentReactions()) { Resend.resend(reaction); @@ -170,29 +153,6 @@ export default class MessageContextMenu extends React.Component { 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 = () => { if (this.props.onCloseDialog) this.props.onCloseDialog(); dis.dispatch({ @@ -285,20 +245,9 @@ export default class MessageContextMenu extends React.Component { const me = cli.getUserId(); const mxEvent = this.props.mxEvent; const eventStatus = mxEvent.status; - const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status; - const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status; 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 resendRedactionButton; let redactButton; - let cancelButton; let forwardButton; let pinButton; let unhidePreviewButton; @@ -309,22 +258,6 @@ export default class MessageContextMenu extends React.Component { // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; if (!mxEvent.isRedacted()) { - if (eventStatus === EventStatus.NOT_SENT) { - resendButton = ( - - { _t('Resend') } - - ); - } - - if (editStatus === EventStatus.NOT_SENT) { - resendEditButton = ( - - { _t('Resend edit') } - - ); - } - if (unsentReactionsCount !== 0) { resendReactionsButton = ( @@ -334,14 +267,6 @@ export default class MessageContextMenu extends React.Component { } } - if (redactStatus === EventStatus.NOT_SENT) { - resendRedactionButton = ( - - { _t('Resend removal') } - - ); - } - if (isSent && this.state.canRedact) { redactButton = ( @@ -350,14 +275,6 @@ export default class MessageContextMenu extends React.Component { ); } - if (allowCancel) { - cancelButton = ( - - { _t('Cancel Sending') } - - ); - } - if (isContentActionable(mxEvent)) { forwardButton = ( @@ -455,12 +372,8 @@ export default class MessageContextMenu extends React.Component { return (
- { resendButton } - { resendEditButton } { resendReactionsButton } - { resendRedactionButton } { redactButton } - { cancelButton } { forwardButton } { pinButton } { viewSourceButton } diff --git a/src/components/views/messages/EditHistoryMessage.js b/src/components/views/messages/EditHistoryMessage.js index e2eda1e12a..dc4e0187d3 100644 --- a/src/components/views/messages/EditHistoryMessage.js +++ b/src/components/views/messages/EditHistoryMessage.js @@ -160,7 +160,6 @@ export default class EditHistoryMessage extends React.PureComponent { "mx_EventTile": true, // Note: we keep the `sending` state class for tests, not for our styles "mx_EventTile_sending": isSending, - "mx_EventTile_notSent": this.state.sendStatus === 'not_sent', }); return (
  • diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 5a6e7d87b7..b2f7f8a692 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -29,6 +29,8 @@ import RoomContext from "../../../contexts/RoomContext"; import Toolbar from "../../../accessibility/Toolbar"; import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {canCancel} from "../context_menus/MessageContextMenu"; +import Resend from "../../../Resend"; const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -169,45 +171,118 @@ export default class MessageActionBar extends React.PureComponent { }); }; - render() { - let reactButton; - let replyButton; - let editButton; + /** + * Runs a given fn on the set of possible events to test. The first event + * that passes the checkFn will have fn executed on it. Both functions take + * 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)) { - if (this.context.canReact) { - reactButton = ( - - ); - } - if (this.context.canReply) { - replyButton = ; + const mxEvent = this.props.mxEvent; + const editEvent = mxEvent.replacingEvent(); + const redactEvent = mxEvent.localRedactionEvent(); + 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), + ); + }; + + render() { + const toolbarOpts = []; if (canEditContent(this.props.mxEvent)) { - editButton = ; + key="edit" + />); } - // aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive. - return - {reactButton} - {replyButton} - {editButton} - ; + + // 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, ); + + // 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, ); + } + if (this.context.canReact) { + toolbarOpts.splice(0, 0, ); + } + } + + if (allowCancel) { + toolbarOpts.push(cancelSendingButton); + } + + // The menu button should be last, so dump it there. + toolbarOpts.push( + key="menu" + />); + } + + // aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive. + return + {toolbarOpts} ; } } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index f6fb83c064..05afb742a4 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -40,6 +40,9 @@ import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStor import {objectHasDiff} from "../../../utils/objects"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import Tooltip from "../elements/Tooltip"; +import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState"; +import {NotificationColor} from "../../../stores/notifications/NotificationColor"; +import NotificationBadge from "./NotificationBadge"; const eventTileTypes = { [EventType.RoomMessage]: 'messages.MessageEvent', @@ -838,7 +841,6 @@ export default class EventTile extends React.Component { mx_EventTile_12hr: this.props.isTwelveHour, // Note: we keep the `sending` state class for tests, not for our styles 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_selected: this.props.isSelectedEvent, mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation, @@ -1253,11 +1255,19 @@ class SentReceipt extends React.PureComponent; + } + let tooltip = null; if (this.state.hover) { let label = _t("Sending your message..."); @@ -1265,6 +1275,8 @@ class SentReceipt extends React.PureComponent + {nonCssBadge} {tooltip} ; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0b525670a8..7af06aa5b1 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1452,6 +1452,7 @@ "Sending your message...": "Sending your message...", "Encrypting your message...": "Encrypting your message...", "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", "Scroll to most recent messages": "Scroll to most recent messages", "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.", "Error decrypting audio": "Error decrypting audio", "React": "React", - "Reply": "Reply", "Edit": "Edit", + "Retry": "Retry", + "Reply": "Reply", "Message Actions": "Message Actions", "Attachment": "Attachment", "Error decrypting attachment": "Error decrypting attachment", @@ -2390,7 +2392,6 @@ "Confirm encryption setup": "Confirm encryption setup", "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", - "Retry": "Retry", "Restoring keys from backup": "Restoring keys from backup", "Fetching keys from server...": "Fetching keys from server...", "%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored", @@ -2419,10 +2420,7 @@ "Reject invitation": "Reject 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", - "Resend edit": "Resend edit", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", - "Resend removal": "Resend removal", - "Cancel Sending": "Cancel Sending", "Forward Message": "Forward Message", "Pin Message": "Pin Message", "Unhide Preview": "Unhide Preview", From cc095c85bfb255ecf45d92ca0baff13d4a797aee Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 21 Apr 2021 16:22:27 -0600 Subject: [PATCH 3/7] Dark theme support --- res/css/structures/_RoomStatusBar.scss | 2 +- res/themes/dark/css/_dark.scss | 2 ++ res/themes/legacy-dark/css/_legacy-dark.scss | 2 ++ res/themes/legacy-light/css/_legacy-light.scss | 2 ++ res/themes/light/css/_light.scss | 2 ++ 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index db8e5a10ba..8cc00aba0f 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -117,7 +117,7 @@ limitations under the License. position: relative; &:nth-child(2) { - border-left: 1px solid $input-darker-bg-color; + border-left: 1px solid $resend-button-divider-color; } &::before { diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index bd7057c3e4..11e6b0202a 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -63,6 +63,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: $bg-color; +$resend-button-divider-color: $muted-fg-color; + // scrollbars $scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-track-color: transparent; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 9b2365a621..adab405fa2 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -61,6 +61,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: $bg-color; +$resend-button-divider-color: $muted-fg-color; + // scrollbars $scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-track-color: transparent; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 0956f433b2..0b1f6cdf37 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -97,6 +97,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: #ffffff; +$resend-button-divider-color: $input-darker-bg-color; + $button-bg-color: $accent-color; $button-fg-color: white; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index b307dbaba3..9904d7553e 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -91,6 +91,8 @@ $field-focused-label-bg-color: #ffffff; $button-bg-color: $accent-color; $button-fg-color: white; +$resend-button-divider-color: $input-darker-bg-color; + // apart from login forms, which have stronger border $strong-input-border-color: #c7c7c7; From 9227618b425a743951d7c82df0eda7922832aac2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 21 Apr 2021 16:36:06 -0600 Subject: [PATCH 4/7] Show indicator in Room List for unsent events --- src/components/structures/RoomStatusBar.js | 2 +- src/components/views/rooms/RoomTile.tsx | 56 ++++++++++++++++------ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index deffd6b95b..42e8a32874 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -34,7 +34,7 @@ const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; -function getUnsentMessages(room) { +export function getUnsentMessages(room) { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index b2a07d7e06..a3207d9d65 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2017, 2019-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +17,7 @@ limitations under the License. import React, { createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; @@ -51,7 +50,10 @@ import IconizedContextMenu, { IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getUnsentMessages } from "../../structures/RoomStatusBar"; +import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; interface IProps { room: Room; @@ -67,6 +69,7 @@ interface IState { notificationsMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect; messagePreview?: string; + hasUnsentEvents: boolean; } const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; @@ -93,6 +96,7 @@ export default class RoomTile extends React.PureComponent { selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, + hasUnsentEvents: this.countUnsentEvents() > 0, // generatePreview() will return nothing if the user has previews disabled messagePreview: this.generatePreview(), @@ -101,6 +105,10 @@ export default class RoomTile extends React.PureComponent { this.roomProps = EchoChamber.forRoom(this.props.room); } + private countUnsentEvents(): number { + return getUnsentMessages(this.props.room).length; + } + private onRoomNameUpdate = (room) => { this.forceUpdate(); } @@ -109,6 +117,11 @@ export default class RoomTile extends React.PureComponent { this.forceUpdate(); // notification state changed - update }; + private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => { + if (!room?.roomId === this.props.room.roomId) return; + this.setState({hasUnsentEvents: this.countUnsentEvents() > 0}); + }; + private onRoomPropertyUpdate = (property: CachedRoomKey) => { if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate(); // else ignore - not important for this tile @@ -167,6 +180,7 @@ export default class RoomTile extends React.PureComponent { CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), this.onCommunityUpdate, ); + MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); } public componentWillUnmount() { @@ -191,6 +205,7 @@ export default class RoomTile extends React.PureComponent { CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), this.onCommunityUpdate, ); + MatrixClientPeg.get()?.off("Room.localEchoUpdated", this.onLocalEchoUpdated); } private onAction = (payload: ActionPayload) => { @@ -554,17 +569,30 @@ export default class RoomTile extends React.PureComponent { />; let badge: React.ReactNode; - if (!this.props.isMinimized && this.notificationState) { + if (!this.props.isMinimized) { // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below - badge = ( - - ); + if (this.state.hasUnsentEvents) { + // hardcode the badge to a danger state when there's unsent messages + badge = ( + + ); + } else if (this.notificationState) { + badge = ( + + ); + } } let messagePreview = null; From 4be9c51dadd116f6ce53359dfdd2bc85beb34ebe Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 21 Apr 2021 16:43:25 -0600 Subject: [PATCH 5/7] Move all the RED_EXCLAMATION badges to a single definition --- src/components/structures/RoomStatusBar.js | 3 +-- src/components/views/rooms/EventTile.js | 3 +-- src/components/views/rooms/RoomList.tsx | 7 ++----- src/components/views/rooms/RoomTile.tsx | 3 +-- src/stores/notifications/StaticNotificationState.ts | 2 ++ 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 42e8a32874..ab4f524faf 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -25,7 +25,6 @@ import {Action} from "../../dispatcher/actions"; import {replaceableComponent} from "../../utils/replaceableComponent"; import {EventStatus} from "matrix-js-sdk/src/models/event"; import NotificationBadge from "../views/rooms/NotificationBadge"; -import {NotificationColor} from "../../stores/notifications/NotificationColor"; import {StaticNotificationState} from "../../stores/notifications/StaticNotificationState"; import AccessibleButton from "../views/elements/AccessibleButton"; import InlineSpinner from "../views/elements/InlineSpinner"; @@ -236,7 +235,7 @@ export default class RoomStatusBar extends React.Component {
    diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 05afb742a4..fb07d3fdff 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -41,7 +41,6 @@ import {objectHasDiff} from "../../../utils/objects"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import Tooltip from "../elements/Tooltip"; import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState"; -import {NotificationColor} from "../../../stores/notifications/NotificationColor"; import NotificationBadge from "./NotificationBadge"; const eventTileTypes = { @@ -1264,7 +1263,7 @@ class SentReceipt extends React.PureComponent; } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 8ac706fc15..5b446d9f13 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017, 2018 Vector Creations Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -37,7 +35,6 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import GroupAvatar from "../avatars/GroupAvatar"; import ExtraTile from "./ExtraTile"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; -import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { Action } from "../../../dispatcher/actions"; import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; @@ -492,7 +489,7 @@ export default class RoomList extends React.PureComponent { isSelected={false} displayName={g.name} avatar={avatar} - notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)} + notificationState={StaticNotificationState.RED_EXCLAMATION} onClick={openGroup} key={`temporaryGroupTile_${g.groupId}`} /> diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index a3207d9d65..c0b52d9c0f 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -53,7 +53,6 @@ import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/Community import { replaceableComponent } from "../../../utils/replaceableComponent"; import { getUnsentMessages } from "../../structures/RoomStatusBar"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; -import { NotificationColor } from "../../../stores/notifications/NotificationColor"; interface IProps { room: Room; @@ -576,7 +575,7 @@ export default class RoomTile extends React.PureComponent { badge = (