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

@ -248,6 +248,7 @@
@import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_NotificationBadge.scss";
@import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_ReadReceiptGroup.scss";
@import "./views/rooms/_RecentlyViewedButton.scss"; @import "./views/rooms/_RecentlyViewedButton.scss";
@import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_ReplyTile.scss";

View file

@ -125,8 +125,8 @@ limitations under the License.
padding-left: 36px; padding-left: 36px;
} }
.mx_EventTile_readAvatars { .mx_ReadReceiptGroup {
top: -10px; top: -6px;
} }
.mx_WhoIsTypingTile { .mx_WhoIsTypingTile {

View file

@ -447,7 +447,7 @@ limitations under the License.
} }
} }
.mx_EventTile_readAvatars { .mx_ReadReceiptGroup {
position: absolute; position: absolute;
right: -78px; // as close to right gutter without clipping as possible right: -78px; // as close to right gutter without clipping as possible
bottom: 0; bottom: 0;
@ -585,8 +585,8 @@ limitations under the License.
right: 127px; // align with that of right-column bubbles right: 127px; // align with that of right-column bubbles
} }
.mx_EventTile_readAvatars { .mx_ReadReceiptGroup {
right: -18px; // match alignment to RRs of chat bubbles right: -14px; // match alignment to RRs of chat bubbles
} }
&::before { &::before {

View file

@ -22,20 +22,18 @@ $left-gutter: 64px;
.mx_EventTile_receiptSent, .mx_EventTile_receiptSent,
.mx_EventTile_receiptSending { .mx_EventTile_receiptSending {
// Give it some dimensions so the tooltip can position properly position: relative;
display: inline-block; display: inline-block;
width: 14px; width: 16px;
height: 14px; height: 16px;
// We don't use `position: relative` on the element because then it won't line
// up with the other read receipts
&::before { &::before {
background-color: $tertiary-content; background-color: $tertiary-content;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
mask-size: 14px; mask-size: 16px;
width: 14px; width: 16px;
height: 14px; height: 16px;
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
@ -349,36 +347,6 @@ $left-gutter: 64px;
} }
} }
.mx_EventTile_readAvatars {
position: relative;
display: inline-block;
width: 14px;
height: 14px;
// This aligns the avatar with the last line of the
// message. We want to move it one line up - 2.2rem
top: -2.2rem;
user-select: none;
z-index: 1;
}
.mx_EventTile_readAvatars .mx_BaseAvatar {
position: absolute;
display: inline-block;
height: $font-14px;
width: $font-14px;
will-change: left, top;
transition:
left var(--transition-short) ease-out,
top var(--transition-standard) ease-out;
}
.mx_EventTile_readAvatarRemainder {
color: $event-timestamp-color;
font-size: $font-11px;
position: absolute;
}
.mx_EventTile_bigEmoji { .mx_EventTile_bigEmoji {
font-size: 48px; font-size: 48px;
line-height: 57px; line-height: 57px;

View file

@ -97,7 +97,7 @@ $left-gutter: 64px;
top: 3px; top: 3px;
} }
.mx_EventTile_readAvatars { .mx_ReadReceiptGroup {
// This aligns the avatar with the last line of the // This aligns the avatar with the last line of the
// message. We want to move it one line up - 2rem // message. We want to move it one line up - 2rem
top: -2rem; top: -2rem;

View file

@ -49,8 +49,8 @@ $irc-line-height: $font-18px;
order: 5; order: 5;
flex-shrink: 0; flex-shrink: 0;
.mx_EventTile_readAvatars { .mx_ReadReceiptGroup {
top: 0.2rem; // ($irc-line-height - avatar height) / 2 top: -0.3rem; // ($irc-line-height - avatar height) / 2
} }
} }

View file

@ -0,0 +1,146 @@
/*
Copyright 2022 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ReadReceiptGroup {
position: relative;
display: inline-block;
// This aligns the avatar with the last line of the
// message. We want to move it one line up
// See .mx_GroupLayout .mx_EventTile .mx_EventTile_line in _GroupLayout.scss
top: calc(-$font-22px - 3px);
user-select: none;
z-index: 1;
.mx_ReadReceiptGroup_button {
display: inline-flex;
flex-direction: row;
height: 16px;
padding: 4px;
border-radius: 6px;
&.mx_AccessibleButton {
&:hover {
background: $event-selected-color;
}
}
}
.mx_ReadReceiptGroup_remainder {
color: $secondary-content;
font-size: $font-11px;
line-height: $font-16px;
margin-right: 4px;
}
.mx_ReadReceiptGroup_container {
position: relative;
display: block;
height: 100%;
.mx_BaseAvatar {
position: absolute;
display: inline-block;
height: 14px;
width: 14px;
border: 1px solid $background;
border-radius: 100%;
will-change: left, top;
transition:
left var(--transition-short) ease-out,
top var(--transition-standard) ease-out;
}
}
}
.mx_ReadReceiptGroup_popup {
max-height: 300px;
width: 220px;
border-radius: 8px;
display: flex;
flex-direction: column;
text-align: left;
font-size: 12px;
line-height: 15px;
right: 0;
&.mx_ContextualMenu_top {
top: 8px;
}
&.mx_ContextualMenu_bottom {
bottom: 8px;
}
.mx_ReadReceiptGroup_title {
font-size: 12px;
line-height: 15px;
margin: 16px 16px 8px;
font-weight: 600;
// shouldnt be actually focusable
outline: none;
}
.mx_AutoHideScrollbar {
.mx_ReadReceiptGroup_person {
display: flex;
flex-direction: row;
padding: 4px;
margin: 0 12px;
border-radius: 8px;
&:hover {
background: $menu-selected-color;
}
&:last-child {
margin-bottom: 8px;
}
.mx_BaseAvatar {
margin: 6px 8px;
align-self: center;
justify-self: center;
}
.mx_ReadReceiptGroup_name {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
p {
margin: 2px 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_ReadReceiptGroup_secondary {
color: $secondary-content;
}
}
}
}
}
.mx_ReadReceiptGroup_person--tooltip {
overflow-y: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -20,13 +20,21 @@ import AccessibleButton from "../../components/views/elements/AccessibleButton";
import { useRovingTabIndex } from "../RovingTabIndex"; import { useRovingTabIndex } from "../RovingTabIndex";
import { Ref } from "./types"; import { Ref } from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "onFocus" | "inputRef" | "tabIndex"> { interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "inputRef" | "tabIndex"> {
inputRef?: Ref; inputRef?: Ref;
} }
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, ...props }) => { export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />; return <AccessibleButton
{...props}
onFocus={event => {
onFocusInternal();
onFocus?.(event);
}}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
/>;
}; };

View file

@ -21,13 +21,21 @@ import { useRovingTabIndex } from "../RovingTabIndex";
import { Ref } from "./types"; import { Ref } from "./types";
type ATBProps = React.ComponentProps<typeof AccessibleTooltipButton>; type ATBProps = React.ComponentProps<typeof AccessibleTooltipButton>;
interface IProps extends Omit<ATBProps, "onFocus" | "inputRef" | "tabIndex"> { interface IProps extends Omit<ATBProps, "inputRef" | "tabIndex"> {
inputRef?: Ref; inputRef?: Ref;
} }
// Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components. // Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components.
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({ inputRef, ...props }) => { export const RovingAccessibleTooltipButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleTooltipButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />; return <AccessibleTooltipButton
{...props}
onFocus={event => {
onFocusInternal();
onFocus?.(event);
}}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
/>;
}; };

View file

@ -82,6 +82,7 @@ export interface IProps extends IPosition {
// whether this context menu should be focus managed. If false it must handle itself // whether this context menu should be focus managed. If false it must handle itself
managed?: boolean; managed?: boolean;
wrapperClassName?: string; wrapperClassName?: string;
menuClassName?: string;
// If true, this context menu will be mounted as a child to the parent container. Otherwise // If true, this context menu will be mounted as a child to the parent container. Otherwise
// it will be mounted to a container at the root of the DOM. // it will be mounted to a container at the root of the DOM.
@ -319,7 +320,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
'mx_ContextualMenu_rightAligned': this.props.rightAligned === true, 'mx_ContextualMenu_rightAligned': this.props.rightAligned === true,
'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true, 'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true,
}); }, this.props.menuClassName);
const menuStyle: CSSProperties = {}; const menuStyle: CSSProperties = {};
if (props.menuWidth) { if (props.menuWidth) {

View file

@ -45,6 +45,7 @@ interface IProps {
onClick?: React.MouseEventHandler; onClick?: React.MouseEventHandler;
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>; inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
className?: string; className?: string;
tabIndex?: number;
} }
const calculateUrls = (url, urls, lowBandwidth) => { const calculateUrls = (url, urls, lowBandwidth) => {

View file

@ -43,6 +43,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
title?: string; title?: string;
style?: any; style?: any;
forceHistorical?: boolean; // true to deny `feature_use_only_current_profiles` usage. Default false. forceHistorical?: boolean; // true to deny `feature_use_only_current_profiles` usage. Default false.
hideTitle?: boolean;
} }
interface IState { interface IState {
@ -124,7 +125,7 @@ export default class MemberAvatar extends React.PureComponent<IProps, IState> {
<BaseAvatar <BaseAvatar
{...otherProps} {...otherProps}
name={this.state.name} name={this.state.name}
title={this.state.title} title={this.props.hideTitle ? undefined : this.state.title}
idName={userId} idName={userId}
url={this.state.imageUrl} url={this.state.imageUrl}
onClick={onClick} onClick={onClick}

View file

@ -32,6 +32,8 @@ export enum Alignment {
Top, // Centered Top, // Centered
Bottom, // Centered Bottom, // Centered
InnerBottom, // Inside the target, at the bottom InnerBottom, // Inside the target, at the bottom
TopRight, // On top of the target, right aligned
TopCenter, // On top of the target, center aligned
} }
export interface ITooltipProps { export interface ITooltipProps {
@ -149,6 +151,16 @@ export default class Tooltip extends React.Component<ITooltipProps> {
style.top = baseTop + parentBox.height - 50; style.top = baseTop + parentBox.height - 50;
style.left = horizontalCenter; style.left = horizontalCenter;
style.transform = "translate(-50%)"; style.transform = "translate(-50%)";
break;
case Alignment.TopRight:
style.top = baseTop - 5;
style.right = width - parentBox.right - window.pageXOffset;
style.transform = "translate(5px, -100%)";
break;
case Alignment.TopCenter:
style.top = baseTop - 5;
style.left = horizontalCenter;
style.transform = "translate(-50%, -100%)";
} }
return style; return style;

View file

@ -36,12 +36,11 @@ import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { E2EState } from "./E2EIcon"; import { E2EState } from "./E2EIcon";
import { toRem } from "../../../utils/units";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import MessageContextMenu from "../context_menus/MessageContextMenu"; import MessageContextMenu from "../context_menus/MessageContextMenu";
import { aboveRightOf } from '../../structures/ContextMenu'; import { aboveRightOf } from '../../structures/ContextMenu';
import { objectHasDiff } from "../../../utils/objects"; import { objectHasDiff } from "../../../utils/objects";
import Tooltip from "../elements/Tooltip"; import Tooltip, { Alignment } from "../elements/Tooltip";
import EditorStateTransfer from "../../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
@ -54,7 +53,7 @@ import MemberAvatar from '../avatars/MemberAvatar';
import SenderProfile from '../messages/SenderProfile'; import SenderProfile from '../messages/SenderProfile';
import MessageTimestamp from '../messages/MessageTimestamp'; import MessageTimestamp from '../messages/MessageTimestamp';
import TooltipButton from '../elements/TooltipButton'; import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker"; import { IReadReceiptInfo } from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar"; import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow'; import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventRenderingUtils'; import { getEventDisplayInfo } from '../../../utils/EventRenderingUtils';
@ -79,6 +78,8 @@ import PosthogTrackers from "../../../PosthogTrackers";
import TileErrorBoundary from '../messages/TileErrorBoundary'; import TileErrorBoundary from '../messages/TileErrorBoundary';
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
import { ReadReceiptGroup } from './ReadReceiptGroup';
import { useTooltip } from "../../../utils/useTooltip";
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
@ -221,9 +222,6 @@ interface IProps {
interface IState { interface IState {
// Whether the action bar is focused. // Whether the action bar is focused.
actionBarFocused: boolean; 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. // Whether the event's sender has been verified.
verified: string; verified: string;
// Whether onRequestKeysClick has been called since mounting. // Whether onRequestKeysClick has been called since mounting.
@ -273,9 +271,6 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
this.state = { this.state = {
// Whether the action bar is focused. // Whether the action bar is focused.
actionBarFocused: false, 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. // Whether the event's sender has been verified.
verified: null, verified: null,
// Whether onRequestKeysClick has been called since mounting. // Whether onRequestKeysClick has been called since mounting.
@ -731,108 +726,6 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
return actions.tweaks.highlight; 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 = () => { private onSenderProfileClick = () => {
dis.dispatch<ComposerInsertPayload>({ dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert, action: Action.ComposerInsert,
@ -1308,8 +1201,17 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
let msgOption; let msgOption;
if (this.props.showReadReceipts) { if (this.props.showReadReceipts) {
const readAvatars = this.getReadAvatars(); if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
msgOption = readAvatars; 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; let replyChain;
@ -1674,30 +1576,9 @@ interface ISentReceiptProps {
messageState: string; // TODO: Types for message sending state messageState: string; // TODO: Types for message sending state
} }
interface ISentReceiptState { function SentReceipt({ messageState }: ISentReceiptProps) {
hover: boolean; const isSent = !messageState || messageState === 'sent';
} const isFailed = messageState === 'not_sent';
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({ const receiptClasses = classNames({
'mx_EventTile_receiptSent': isSent, 'mx_EventTile_receiptSent': isSent,
'mx_EventTile_receiptSending': !isSent && !isFailed, 'mx_EventTile_receiptSending': !isSent && !isFailed,
@ -1705,35 +1586,41 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
let nonCssBadge = null; let nonCssBadge = null;
if (isFailed) { if (isFailed) {
nonCssBadge = <NotificationBadge nonCssBadge = (
notification={StaticNotificationState.RED_EXCLAMATION} <NotificationBadge notification={StaticNotificationState.RED_EXCLAMATION} />
/>; );
} }
let tooltip = null;
if (this.state.hover) {
let label = _t("Sending your message..."); let label = _t("Sending your message...");
if (this.props.messageState === 'encrypting') { if (messageState === 'encrypting') {
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) { } else if (isFailed) {
label = _t("Failed to send"); label = _t("Failed to send");
} }
// The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
// with the read receipt. label: label,
tooltip = <Tooltip className="mx_EventTile_readAvatars_receiptTooltip" label={label} yOffset={3} />; alignment: Alignment.TopRight,
} });
return ( return (
<div className="mx_EventTile_msgOption"> <div className="mx_EventTile_msgOption">
<span className="mx_EventTile_readAvatars"> <div className="mx_ReadReceiptGroup">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}> <div
className="mx_ReadReceiptGroup_button"
onMouseOver={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}>
<span className="mx_ReadReceiptGroup_container">
<span className={receiptClasses}>
{ nonCssBadge } { nonCssBadge }
{ tooltip }
</span> </span>
</span> </span>
</div> </div>
{ tooltip }
</div>
</div>
); );
} }
}

View file

@ -0,0 +1,254 @@
/*
Copyright 2022 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { PropsWithChildren, useRef } from "react";
import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker";
import { IReadReceiptProps } from "./EventTile";
import AccessibleButton from "../elements/AccessibleButton";
import MemberAvatar from "../avatars/MemberAvatar";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { Alignment } from "../elements/Tooltip";
import { formatDate } from "../../../DateUtils";
import { Action } from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
import ContextMenu, { aboveLeftOf, MenuItem, useContextMenu } from "../../structures/ContextMenu";
import { useTooltip } from "../../../utils/useTooltip";
import { _t } from "../../../languageHandler";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
const MAX_READ_AVATARS = 3;
const READ_AVATAR_OFFSET = 10;
export const READ_AVATAR_SIZE = 16;
interface Props {
readReceipts: IReadReceiptProps[];
readReceiptMap: { [userId: string]: IReadReceiptInfo };
checkUnmounting: () => boolean;
suppressAnimation: boolean;
isTwelveHour: boolean;
}
// Design specified that we should show the three latest read receipts
function determineAvatarPosition(index, count): [boolean, number] {
const firstVisible = Math.max(0, count - MAX_READ_AVATARS);
if (index >= firstVisible) {
return [false, index - firstVisible];
} else {
return [true, 0];
}
}
export function ReadReceiptGroup(
{ readReceipts, readReceiptMap, checkUnmounting, suppressAnimation, isTwelveHour }: Props,
) {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
label: _t("Seen by %(count)s people", { count: readReceipts.length }),
alignment: Alignment.TopRight,
});
// return early if there are no read receipts
if (readReceipts.length === 0) {
// We currently must include `mx_ReadReceiptGroup_container` 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">
<div className="mx_ReadReceiptGroup">
<div className="mx_ReadReceiptGroup_button">
<span className="mx_ReadReceiptGroup_container" />
</div>
</div>
</div>
);
}
const avatars = readReceipts.map((receipt, index) => {
const [hidden, position] = determineAvatarPosition(index, readReceipts.length);
const userId = receipt.userId;
let readReceiptInfo: IReadReceiptInfo;
if (readReceiptMap) {
readReceiptInfo = readReceiptMap[userId];
if (!readReceiptInfo) {
readReceiptInfo = {};
readReceiptMap[userId] = readReceiptInfo;
}
}
return (
<ReadReceiptMarker
key={userId}
member={receipt.roomMember}
fallbackUserId={userId}
offset={position * READ_AVATAR_OFFSET}
hidden={hidden}
readReceiptInfo={readReceiptInfo}
checkUnmounting={checkUnmounting}
suppressAnimation={suppressAnimation}
timestamp={receipt.ts}
showTwelveHour={isTwelveHour}
/>
);
});
let remText: JSX.Element;
const remainder = readReceipts.length - MAX_READ_AVATARS;
if (remainder > 0) {
remText = (
<span className="mx_ReadReceiptGroup_remainder" aria-live="off">
+{ remainder }
</span>
);
}
let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
contextMenu = (
<ContextMenu
menuClassName="mx_ReadReceiptGroup_popup"
onFinished={closeMenu}
{...aboveLeftOf(buttonRect)}>
<AutoHideScrollbar>
<SectionHeader className="mx_ReadReceiptGroup_title">
{ _t("Seen by %(count)s people", { count: readReceipts.length }) }
</SectionHeader>
{ readReceipts.map(receipt => (
<ReadReceiptPerson
key={receipt.userId}
{...receipt}
isTwelveHour={isTwelveHour}
onAfterClick={closeMenu}
/>
)) }
</AutoHideScrollbar>
</ContextMenu>
);
}
return (
<div className="mx_EventTile_msgOption">
<div className="mx_ReadReceiptGroup">
<AccessibleButton
className="mx_ReadReceiptGroup_button"
inputRef={button}
onClick={openMenu}
onMouseOver={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}>
{ remText }
<span
className="mx_ReadReceiptGroup_container"
style={{
width: Math.min(MAX_READ_AVATARS, readReceipts.length) * READ_AVATAR_OFFSET +
READ_AVATAR_SIZE - READ_AVATAR_OFFSET,
}}
>
{ avatars }
</span>
</AccessibleButton>
{ tooltip }
{ contextMenu }
</div>
</div>
);
}
interface ReadReceiptPersonProps extends IReadReceiptProps {
isTwelveHour: boolean;
onAfterClick?: () => void;
}
function ReadReceiptPerson({ userId, roomMember, ts, isTwelveHour, onAfterClick }: ReadReceiptPersonProps) {
const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
alignment: Alignment.TopCenter,
tooltipClassName: "mx_ReadReceiptGroup_person--tooltip",
label: (
<>
<div className="mx_Tooltip_title">
{ roomMember.name ?? userId }
</div>
<div className="mx_Tooltip_sub">
{ userId }
</div>
</>
),
});
return (
<MenuItem
className="mx_ReadReceiptGroup_person"
onClick={() => {
dis.dispatch({
action: Action.ViewUser,
member: roomMember,
push: false,
});
onAfterClick?.();
}}
onMouseOver={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
onWheel={hideTooltip}>
<MemberAvatar
member={roomMember}
fallbackUserId={userId}
width={24}
height={24}
aria-hidden="true"
aria-live="off"
resizeMethod="crop" />
<div className="mx_ReadReceiptGroup_name">
<p>{ roomMember.name }</p>
<p className="mx_ReadReceiptGroup_secondary">
{ formatDate(new Date(ts), isTwelveHour) }
</p>
</div>
{ tooltip }
</MenuItem>
);
}
interface ISectionHeaderProps {
className?: string;
}
function SectionHeader({ className, children }: PropsWithChildren<ISectionHeaderProps>) {
const ref = useRef<HTMLHeadingElement>();
const [onFocus] = useRovingTabIndex(ref);
return (
<h3
className={className}
role="menuitem"
onFocus={onFocus}
tabIndex={-1}
ref={ref}
>
{ children }
</h3>
);
}

View file

@ -19,15 +19,13 @@ import React, { createRef, RefObject } from 'react';
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../../languageHandler';
import { formatDate } from '../../../DateUtils';
import NodeAnimator from "../../../NodeAnimator"; import NodeAnimator from "../../../NodeAnimator";
import { toPx } from "../../../utils/units"; import { toPx } from "../../../utils/units";
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
export interface IReadReceiptInfo { export interface IReadReceiptInfo {
top?: number; top?: number;
left?: number; right?: number;
parent?: Element; parent?: Element;
} }
@ -40,7 +38,7 @@ interface IProps {
// number of pixels to offset the avatar from the right of its parent; // number of pixels to offset the avatar from the right of its parent;
// typically a negative value. // typically a negative value.
leftOffset?: number; offset: number;
// true to hide the avatar (it will still be animated) // true to hide the avatar (it will still be animated)
hidden?: boolean; hidden?: boolean;
@ -56,9 +54,6 @@ interface IProps {
// are being unmounted. // are being unmounted.
checkUnmounting?: () => boolean; checkUnmounting?: () => boolean;
// callback for clicks on this RR
onClick?: (e: React.MouseEvent) => void;
// Timestamp when the receipt was read // Timestamp when the receipt was read
timestamp?: number; timestamp?: number;
@ -73,16 +68,12 @@ interface IState {
interface IReadReceiptMarkerStyle { interface IReadReceiptMarkerStyle {
top: number; top: number;
left: number; right: number;
} }
export default class ReadReceiptMarker extends React.PureComponent<IProps, IState> { export default class ReadReceiptMarker extends React.PureComponent<IProps, IState> {
private avatar: React.RefObject<HTMLDivElement | HTMLImageElement | HTMLSpanElement> = createRef(); private avatar: React.RefObject<HTMLDivElement | HTMLImageElement | HTMLSpanElement> = createRef();
static defaultProps = {
leftOffset: 0,
};
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -112,7 +103,7 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
const avatarNode = this.avatar.current; const avatarNode = this.avatar.current;
rrInfo.top = avatarNode.offsetTop; rrInfo.top = avatarNode.offsetTop;
rrInfo.left = avatarNode.offsetLeft; rrInfo.right = avatarNode.getBoundingClientRect().right - avatarNode.offsetParent.getBoundingClientRect().right;
rrInfo.parent = avatarNode.offsetParent; rrInfo.parent = avatarNode.offsetParent;
} }
@ -125,9 +116,9 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
} }
public componentDidUpdate(prevProps: IProps): void { public componentDidUpdate(prevProps: IProps): void {
const differentLeftOffset = prevProps.leftOffset !== this.props.leftOffset; const differentOffset = prevProps.offset !== this.props.offset;
const visibilityChanged = prevProps.hidden !== this.props.hidden; const visibilityChanged = prevProps.hidden !== this.props.hidden;
if (differentLeftOffset || visibilityChanged) { if (differentOffset || visibilityChanged) {
this.animateMarker(); this.animateMarker();
} }
} }
@ -157,13 +148,13 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
const startStyles = []; const startStyles = [];
if (oldInfo && oldInfo.left) { if (oldInfo && oldInfo.right) {
// start at the old height and in the old h pos // start at the old height and in the old h pos
startStyles.push({ top: startTopOffset+"px", startStyles.push({ top: startTopOffset+"px",
left: toPx(oldInfo.left) }); right: toPx(oldInfo.right) });
} }
startStyles.push({ top: startTopOffset+'px', left: '0' }); startStyles.push({ top: startTopOffset+'px', right: '0' });
this.setState({ this.setState({
suppressDisplay: false, suppressDisplay: false,
@ -177,29 +168,10 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
} }
const style = { const style = {
left: toPx(this.props.leftOffset), right: toPx(this.props.offset),
top: '0px', top: '0px',
}; };
let title;
if (this.props.timestamp) {
const dateString = formatDate(new Date(this.props.timestamp), this.props.showTwelveHour);
if (!this.props.member || this.props.fallbackUserId === this.props.member.rawDisplayName) {
title = _t(
"Seen by %(userName)s at %(dateTime)s",
{ userName: this.props.fallbackUserId,
dateTime: dateString },
);
} else {
title = _t(
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s",
{ displayName: this.props.member.rawDisplayName,
userName: this.props.fallbackUserId,
dateTime: dateString },
);
}
}
return ( return (
<NodeAnimator startStyles={this.state.startStyles}> <NodeAnimator startStyles={this.state.startStyles}>
<MemberAvatar <MemberAvatar
@ -211,9 +183,9 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
height={14} height={14}
resizeMethod="crop" resizeMethod="crop"
style={style} style={style}
title={title}
onClick={this.props.onClick}
inputRef={this.avatar as RefObject<HTMLImageElement>} inputRef={this.avatar as RefObject<HTMLImageElement>}
hideTitle
tabIndex={-1}
/> />
</NodeAnimator> </NodeAnimator>
); );

View file

@ -1763,8 +1763,8 @@
"Preview": "Preview", "Preview": "Preview",
"View": "View", "View": "View",
"Join": "Join", "Join": "Join",
"Seen by %(userName)s at %(dateTime)s": "Seen by %(userName)s at %(dateTime)s", "Seen by %(count)s people|other": "Seen by %(count)s people",
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "Seen by %(displayName)s (%(userName)s) at %(dateTime)s", "Seen by %(count)s people|one": "Seen by %(count)s person",
"Recently viewed": "Recently viewed", "Recently viewed": "Recently viewed",
"Replying": "Replying", "Replying": "Replying",
"Room %(name)s": "Room %(name)s", "Room %(name)s": "Room %(name)s",

24
src/utils/useTooltip.tsx Normal file
View file

@ -0,0 +1,24 @@
import React, { ComponentProps, useState } from "react";
import Tooltip from "../components/views/elements/Tooltip";
interface TooltipEvents {
showTooltip: () => void;
hideTooltip: () => void;
}
export function useTooltip(props: ComponentProps<typeof Tooltip>): [TooltipEvents, JSX.Element | null] {
const [isVisible, setIsVisible] = useState(false);
const showTooltip = () => setIsVisible(true);
const hideTooltip = () => setIsVisible(false);
// No need to fill up the DOM with hidden tooltip elements. Only add the
// tooltip when we're hovering over the item (performance)
const tooltip = <Tooltip
{...props}
visible={isVisible}
/>;
return [{ showTooltip, hideTooltip }, tooltip];
}