Implement new Read Receipt design (#8389)

* feat: introduce new alignment types for tooltip
* feat: introduce new hook for tooltips
* feat: allow using onFocus callback for RovingAccessibleButton
* feat: allow using custom class for ContextMenu
* feat: allow setting tab index for avatar
* refactor: move read receipts out of event tile
* feat: implement new read receipt design
* feat: update SentReceipt to match new read receipts as well
This commit is contained in:
Janne Mareike Koschinski 2022-04-22 17:09:44 +02:00 committed by GitHub
parent 03c46770f4
commit ee2ee3c08c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 553 additions and 270 deletions

View file

@ -36,12 +36,11 @@ import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { E2EState } from "./E2EIcon";
import { toRem } from "../../../utils/units";
import RoomAvatar from "../avatars/RoomAvatar";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import { aboveRightOf } from '../../structures/ContextMenu';
import { objectHasDiff } from "../../../utils/objects";
import Tooltip from "../elements/Tooltip";
import Tooltip, { Alignment } from "../elements/Tooltip";
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
@ -54,7 +53,7 @@ import MemberAvatar from '../avatars/MemberAvatar';
import SenderProfile from '../messages/SenderProfile';
import MessageTimestamp from '../messages/MessageTimestamp';
import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker";
import { IReadReceiptInfo } from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventRenderingUtils';
@ -79,6 +78,8 @@ import PosthogTrackers from "../../../PosthogTrackers";
import TileErrorBoundary from '../messages/TileErrorBoundary';
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
import { ReadReceiptGroup } from './ReadReceiptGroup';
import { useTooltip } from "../../../utils/useTooltip";
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
@ -221,9 +222,6 @@ interface IProps {
interface IState {
// Whether the action bar is focused.
actionBarFocused: boolean;
// Whether all read receipts are being displayed. If not, only display
// a truncation of them.
allReadAvatars: boolean;
// Whether the event's sender has been verified.
verified: string;
// Whether onRequestKeysClick has been called since mounting.
@ -273,9 +271,6 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
this.state = {
// Whether the action bar is focused.
actionBarFocused: false,
// Whether all read receipts are being displayed. If not, only display
// a truncation of them.
allReadAvatars: false,
// Whether the event's sender has been verified.
verified: null,
// Whether onRequestKeysClick has been called since mounting.
@ -731,108 +726,6 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
return actions.tweaks.highlight;
}
private toggleAllReadAvatars = () => {
this.setState({
allReadAvatars: !this.state.allReadAvatars,
});
};
private getReadAvatars() {
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
}
const MAX_READ_AVATARS = this.props.layout == Layout.Bubble
? 2
: 5;
// return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
// We currently must include `mx_EventTile_readAvatars` in the DOM
// of all events, as it is the positioned parent of the animated
// read receipts. We can't let it unmount when a receipt moves
// events, so for now we mount it for all events. Without it, the
// animation will start from the top of the timeline (because it
// lost its container).
// See also https://github.com/vector-im/element-web/issues/17561
return (
<div className="mx_EventTile_msgOption">
<span className="mx_EventTile_readAvatars" />
</div>
);
}
const avatars = [];
const receiptOffset = 15;
let left = 0;
const receipts = this.props.readReceipts;
for (let i = 0; i < receipts.length; ++i) {
const receipt = receipts[i];
let hidden = true;
if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) {
hidden = false;
}
// TODO: we keep the extra read avatars in the dom to make animation simpler
// we could optimise this to reduce the dom size.
// If hidden, set offset equal to the offset of the final visible avatar or
// else set it proportional to index
left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset;
const userId = receipt.userId;
let readReceiptInfo: IReadReceiptInfo;
if (this.props.readReceiptMap) {
readReceiptInfo = this.props.readReceiptMap[userId];
if (!readReceiptInfo) {
readReceiptInfo = {};
this.props.readReceiptMap[userId] = readReceiptInfo;
}
}
// add to the start so the most recent is on the end (ie. ends up rightmost)
avatars.unshift(
<ReadReceiptMarker
key={userId}
member={receipt.roomMember}
fallbackUserId={userId}
leftOffset={left}
hidden={hidden}
readReceiptInfo={readReceiptInfo}
checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this.suppressReadReceiptAnimation}
onClick={this.toggleAllReadAvatars}
timestamp={receipt.ts}
showTwelveHour={this.props.isTwelveHour}
/>,
);
}
let remText: JSX.Element;
if (!this.state.allReadAvatars) {
const remainder = receipts.length - MAX_READ_AVATARS;
if (remainder > 0) {
remText = <span className="mx_EventTile_readAvatarRemainder"
onClick={this.toggleAllReadAvatars}
style={{ right: "calc(" + toRem(-left) + " + " + receiptOffset + "px)" }}
aria-live="off">{ remainder }+
</span>;
}
}
return (
<div className="mx_EventTile_msgOption">
<span className="mx_EventTile_readAvatars">
{ remText }
{ avatars }
</span>
</div>
);
}
private onSenderProfileClick = () => {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
@ -1308,8 +1201,17 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
let msgOption;
if (this.props.showReadReceipts) {
const readAvatars = this.getReadAvatars();
msgOption = readAvatars;
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
msgOption = <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
} else {
msgOption = <ReadReceiptGroup
readReceipts={this.props.readReceipts ?? []}
readReceiptMap={this.props.readReceiptMap ?? {}}
checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this.suppressReadReceiptAnimation}
isTwelveHour={this.props.isTwelveHour}
/>;
}
}
let replyChain;
@ -1674,66 +1576,51 @@ interface ISentReceiptProps {
messageState: string; // TODO: Types for message sending state
}
interface ISentReceiptState {
hover: boolean;
}
function SentReceipt({ messageState }: ISentReceiptProps) {
const isSent = !messageState || messageState === 'sent';
const isFailed = messageState === 'not_sent';
const receiptClasses = classNames({
'mx_EventTile_receiptSent': isSent,
'mx_EventTile_receiptSending': !isSent && !isFailed,
});
class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptState> {
constructor(props) {
super(props);
this.state = {
hover: false,
};
}
onHoverStart = () => {
this.setState({ hover: true });
};
onHoverEnd = () => {
this.setState({ hover: false });
};
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 && !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...");
if (this.props.messageState === 'encrypting') {
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.
tooltip = <Tooltip className="mx_EventTile_readAvatars_receiptTooltip" label={label} yOffset={3} />;
}
return (
<div className="mx_EventTile_msgOption">
<span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{ nonCssBadge }
{ tooltip }
</span>
</span>
</div>
let nonCssBadge = null;
if (isFailed) {
nonCssBadge = (
<NotificationBadge notification={StaticNotificationState.RED_EXCLAMATION} />
);
}
let label = _t("Sending your message...");
if (messageState === 'encrypting') {
label = _t("Encrypting your message...");
} else if (isSent) {
label = _t("Your message was sent");
} else if (isFailed) {
label = _t("Failed to send");
}
const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
label: label,
alignment: Alignment.TopRight,
});
return (
<div className="mx_EventTile_msgOption">
<div className="mx_ReadReceiptGroup">
<div
className="mx_ReadReceiptGroup_button"
onMouseOver={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}>
<span className="mx_ReadReceiptGroup_container">
<span className={receiptClasses}>
{ nonCssBadge }
</span>
</span>
</div>
{ tooltip }
</div>
</div>
);
}