Merge pull request #5897 from matrix-org/travis/error-states

Improve message error states
This commit is contained in:
Travis Ralston 2021-04-26 08:16:26 -06:00 committed by GitHub
commit ddb8947c15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 368 additions and 225 deletions

View file

@ -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 $resend-button-divider-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;
}

View file

@ -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');
}

View file

@ -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

View file

@ -0,0 +1,3 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58365 3.90848C5.79757 2.94852 7.33285 2.375 9 2.375C12.6817 2.375 15.7112 5.1675 16.086 8.75H17.6314C17.9253 8.75 18.1006 9.07792 17.9376 9.32274L15.6812 12.711C15.5355 12.9297 15.2145 12.9297 15.0688 12.711L12.8124 9.32274C12.6494 9.07792 12.8247 8.75 13.1186 8.75H14.5754C14.2088 5.99798 11.8523 3.875 9 3.875C7.68247 3.875 6.4726 4.32705 5.51407 5.08504C5.45221 5.13396 5.39899 5.17326 5.36001 5.20114C5.34047 5.21513 5.32433 5.22637 5.31229 5.23463L5.29733 5.24482L5.29227 5.24821L5.29037 5.24948L5.28958 5.25L5.28923 5.25023L5.28906 5.25034L5.28898 5.2504L4.875 4.625L5.2889 5.25045C4.94347 5.47904 4.47814 5.38433 4.24955 5.0389C4.02136 4.69408 4.11534 4.22977 4.45929 4.00075L4.4633 3.99802C4.46789 3.99487 4.47605 3.9892 4.48719 3.98123C4.5096 3.9652 4.5433 3.94038 4.58365 3.90848ZM3.42456 10.25H4.88138C5.1753 10.25 5.35061 9.92208 5.18758 9.67726L2.93119 6.28905C2.78553 6.07032 2.46447 6.07032 2.31881 6.28905L0.0624241 9.67726C-0.100613 9.92207 0.0746987 10.25 0.368618 10.25H1.914C2.28878 13.8325 5.31828 16.625 9 16.625C10.7415 16.625 12.3388 15.9992 13.5764 14.9611C13.8938 14.6949 13.9353 14.2219 13.6691 13.9045C13.4029 13.5872 12.9298 13.5457 12.6125 13.8119C11.6349 14.6319 10.376 15.125 9 15.125C6.14769 15.125 3.79123 13.002 3.42456 10.25Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,3 @@
<svg width="12" height="17" viewBox="0 0 12 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.857143 14.5C0.857143 15.4491 1.62857 16.5 2.57143 16.5H9.42857C10.3714 16.5 11.1429 15.2542 11.1429 14.3051V5.67692C11.1429 4.72781 10.3714 3.95128 9.42857 3.95128H2.57143C1.62857 3.95128 0.857143 4.72781 0.857143 5.67692V14.5ZM11.1429 1.36282H9L8.39143 0.750218C8.23714 0.59491 8.01429 0.5 7.79143 0.5H4.20857C3.98571 0.5 3.76286 0.59491 3.60857 0.750218L3 1.36282H0.857143C0.385714 1.36282 0 1.75109 0 2.22564C0 2.70019 0.385714 3.08846 0.857143 3.08846H11.1429C11.6143 3.08846 12 2.70019 12 2.22564C12 1.75109 11.6143 1.36282 11.1429 1.36282Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

View file

@ -63,6 +63,8 @@ $input-invalid-border-color: $warning-color;
$field-focused-label-bg-color: $bg-color;
$resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity.
// scrollbars
$scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
$scrollbar-track-color: transparent;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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,16 +20,20 @@ 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 {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;
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;
@ -76,6 +80,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 +114,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 +128,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 +146,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 +167,6 @@ export default class RoomStatusBar extends React.Component {
_getUnsentMessageContent() {
const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
let title;
@ -206,75 +210,76 @@ export default class RoomStatusBar extends React.Component {
"Please <a>contact your service administrator</a> 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 <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> " +
"now. You can also select individual messages to resend or cancel.",
{ count: unsentMessages.length },
{
'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
'cancelText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
let buttonRow = <>
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{_t("Delete all")}
</AccessibleButton>
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
{_t("Retry all")}
</AccessibleButton>
</>;
if (this.state.isResending) {
buttonRow = <>
<InlineSpinner w={20} h={20} />
{/* span for css */}
<span>{_t("Sending")}</span>
</>;
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
return <>
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div role="alert">
<div className="mx_RoomStatusBar_unsentBadge">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>
</div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">
{ title }
</div>
<div className="mx_RoomStatusBar_unsentDescription">
{ _t("You can select all or individual messages to retry or delete") }
</div>
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">
{buttonRow}
</div>
</div>
</div>
</div>;
</>;
}
// return suitable content for the main (text) part of the status bar.
_getContent() {
render() {
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
<div className="mx_RoomStatusBar">
<div role="alert">
<div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
height="24" title="/!\ " alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{_t('Connectivity to the server has been lost.')}
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{_t('Sent messages will be stored until your connection has returned.')}
</div>
</div>
</div>
</div>
</div>
);
}
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 (
<div className="mx_RoomStatusBar">
<div role="alert">
{ content }
</div>
</div>
);
}
}

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 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 = (
<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) {
resendReactionsButton = (
<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) {
redactButton = (
<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)) {
forwardButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
@ -455,12 +372,8 @@ export default class MessageContextMenu extends React.Component {
return (
<div className="mx_MessageContextMenu">
{ resendButton }
{ resendEditButton }
{ resendReactionsButton }
{ resendRedactionButton }
{ redactButton }
{ cancelButton }
{ forwardButton }
{ pinButton }
{ viewSourceButton }

View file

@ -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 (
<li>

View file

@ -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 = (
<ReactButton mxEvent={this.props.mxEvent} reactions={this.props.reactions} onFocusChange={this.onFocusChange} />
);
}
if (this.context.canReply) {
replyButton = <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
/>;
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 = <RovingAccessibleTooltipButton
toolbarOpts.push(<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
title={_t("Edit")}
onClick={this.onEditClick}
/>;
key="edit"
/>);
}
// 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">
{reactButton}
{replyButton}
{editButton}
<OptionsButton
const cancelSendingButton = <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_cancelButton"
title={_t("Delete")}
onClick={this.onCancelClick}
key="cancel"
/>;
// 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}
getReplyThread={this.props.getReplyThread}
getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator}
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>;
}
}

View file

@ -40,6 +40,8 @@ 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 NotificationBadge from "./NotificationBadge";
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -838,7 +840,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 +1254,19 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
render() {
const isSent = !this.props.messageState || this.props.messageState === 'sent';
const isFailed = this.props.messageState === 'not_sent';
const receiptClasses = classNames({
'mx_EventTile_receiptSent': isSent,
'mx_EventTile_receiptSending': !isSent,
'mx_EventTile_receiptSending': !isSent && !isFailed,
});
let nonCssBadge = null;
if (isFailed) {
nonCssBadge = <NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>;
}
let tooltip = null;
if (this.state.hover) {
let label = _t("Sending your message...");
@ -1265,6 +1274,8 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
label = _t("Encrypting your message...");
} else if (isSent) {
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
// with the read receipt.
@ -1273,6 +1284,7 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
return <span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{nonCssBadge}
{tooltip}
</span>
</span>;

View file

@ -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.

View file

@ -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<IProps, IState> {
isSelected={false}
displayName={g.name}
avatar={avatar}
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
notificationState={StaticNotificationState.RED_EXCLAMATION}
onClick={openGroup}
key={`temporaryGroupTile_${g.groupId}`}
/>

View file

@ -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,9 @@ 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";
interface IProps {
room: Room;
@ -67,6 +68,7 @@ interface IState {
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
messagePreview?: string;
hasUnsentEvents: boolean;
}
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -93,6 +95,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
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 +104,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.roomProps = EchoChamber.forRoom(this.props.room);
}
private countUnsentEvents(): number {
return getUnsentMessages(this.props.room).length;
}
private onRoomNameUpdate = (room) => {
this.forceUpdate();
}
@ -109,6 +116,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
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 +179,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
public componentWillUnmount() {
@ -191,6 +204,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
private onAction = (payload: ActionPayload) => {
@ -554,17 +568,30 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
/>;
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 = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
if (this.state.hasUnsentEvents) {
// hardcode the badge to a danger state when there's unsent messages
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
} else if (this.notificationState) {
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
}
let messagePreview = null;

View file

@ -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.",
@ -1453,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",
@ -1811,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",
@ -2397,7 +2398,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",
@ -2426,10 +2426,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",
@ -2617,10 +2614,11 @@
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
"Your message wasn't sent because this homeserver has been blocked by it's administrator. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please <a>contact your service administrator</a> to continue using the service.",
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> 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 <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|other": "<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.",
"%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|one": "<resendText>Resend message</resendText> or <cancelText>cancel message</cancelText> 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?",

View file

@ -18,6 +18,8 @@ import { NotificationColor } from "./NotificationColor";
import { NotificationState } from "./NotificationState";
export class StaticNotificationState extends NotificationState {
public static readonly RED_EXCLAMATION = StaticNotificationState.forSymbol("!", NotificationColor.Red);
constructor(symbol: string, count: number, color: NotificationColor) {
super();
this._symbol = symbol;

View file

@ -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(

View file

@ -29,7 +29,10 @@ function waitForRoomListStoreUpdate() {
describe('RoomList', () => {
function createRoom(opts) {
const room = new Room(generateRoomId(), null, client.getUserId());
const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), {
// The room list now uses getPendingEvents(), so we need a detached ordering.
pendingEventOrdering: "detached",
});
if (opts) {
Object.assign(room, opts);
}

View file

@ -79,6 +79,13 @@ export function createTestClient() {
generateClientSecret: () => "t35tcl1Ent5ECr3T",
isGuest: () => false,
isCryptoEnabled: () => false,
// Used by various internal bits we aren't concerned with (yet)
_sessionStore: {
store: {
getItem: jest.fn(),
},
},
};
}