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:
parent
03c46770f4
commit
ee2ee3c08c
18 changed files with 553 additions and 270 deletions
|
@ -248,6 +248,7 @@
|
|||
@import "./views/rooms/_NotificationBadge.scss";
|
||||
@import "./views/rooms/_PinnedEventTile.scss";
|
||||
@import "./views/rooms/_PresenceLabel.scss";
|
||||
@import "./views/rooms/_ReadReceiptGroup.scss";
|
||||
@import "./views/rooms/_RecentlyViewedButton.scss";
|
||||
@import "./views/rooms/_ReplyPreview.scss";
|
||||
@import "./views/rooms/_ReplyTile.scss";
|
||||
|
|
|
@ -125,8 +125,8 @@ limitations under the License.
|
|||
padding-left: 36px;
|
||||
}
|
||||
|
||||
.mx_EventTile_readAvatars {
|
||||
top: -10px;
|
||||
.mx_ReadReceiptGroup {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.mx_WhoIsTypingTile {
|
||||
|
|
|
@ -447,7 +447,7 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
.mx_EventTile_readAvatars {
|
||||
.mx_ReadReceiptGroup {
|
||||
position: absolute;
|
||||
right: -78px; // as close to right gutter without clipping as possible
|
||||
bottom: 0;
|
||||
|
@ -585,8 +585,8 @@ limitations under the License.
|
|||
right: 127px; // align with that of right-column bubbles
|
||||
}
|
||||
|
||||
.mx_EventTile_readAvatars {
|
||||
right: -18px; // match alignment to RRs of chat bubbles
|
||||
.mx_ReadReceiptGroup {
|
||||
right: -14px; // match alignment to RRs of chat bubbles
|
||||
}
|
||||
|
||||
&::before {
|
||||
|
|
|
@ -22,20 +22,18 @@ $left-gutter: 64px;
|
|||
|
||||
.mx_EventTile_receiptSent,
|
||||
.mx_EventTile_receiptSending {
|
||||
// Give it some dimensions so the tooltip can position properly
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
// We don't use `position: relative` on the element because then it won't line
|
||||
// up with the other read receipts
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&::before {
|
||||
background-color: $tertiary-content;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
mask-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
position: absolute;
|
||||
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 {
|
||||
font-size: 48px;
|
||||
line-height: 57px;
|
||||
|
|
|
@ -97,7 +97,7 @@ $left-gutter: 64px;
|
|||
top: 3px;
|
||||
}
|
||||
|
||||
.mx_EventTile_readAvatars {
|
||||
.mx_ReadReceiptGroup {
|
||||
// This aligns the avatar with the last line of the
|
||||
// message. We want to move it one line up - 2rem
|
||||
top: -2rem;
|
||||
|
|
|
@ -49,8 +49,8 @@ $irc-line-height: $font-18px;
|
|||
order: 5;
|
||||
flex-shrink: 0;
|
||||
|
||||
.mx_EventTile_readAvatars {
|
||||
top: 0.2rem; // ($irc-line-height - avatar height) / 2
|
||||
.mx_ReadReceiptGroup {
|
||||
top: -0.3rem; // ($irc-line-height - avatar height) / 2
|
||||
}
|
||||
}
|
||||
|
||||
|
|
146
res/css/views/rooms/_ReadReceiptGroup.scss
Normal file
146
res/css/views/rooms/_ReadReceiptGroup.scss
Normal 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;
|
||||
// shouldn’t 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;
|
||||
}
|
|
@ -20,13 +20,21 @@ import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
|||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
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;
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
|
||||
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
|
||||
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => {
|
||||
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleButton
|
||||
{...props}
|
||||
onFocus={event => {
|
||||
onFocusInternal();
|
||||
onFocus?.(event);
|
||||
}}
|
||||
inputRef={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>;
|
||||
};
|
||||
|
||||
|
|
|
@ -21,13 +21,21 @@ import { useRovingTabIndex } from "../RovingTabIndex";
|
|||
import { Ref } from "./types";
|
||||
|
||||
type ATBProps = React.ComponentProps<typeof AccessibleTooltipButton>;
|
||||
interface IProps extends Omit<ATBProps, "onFocus" | "inputRef" | "tabIndex"> {
|
||||
interface IProps extends Omit<ATBProps, "inputRef" | "tabIndex"> {
|
||||
inputRef?: Ref;
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components.
|
||||
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({ inputRef, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleTooltipButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
|
||||
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => {
|
||||
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleTooltipButton
|
||||
{...props}
|
||||
onFocus={event => {
|
||||
onFocusInternal();
|
||||
onFocus?.(event);
|
||||
}}
|
||||
inputRef={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>;
|
||||
};
|
||||
|
||||
|
|
|
@ -82,6 +82,7 @@ export interface IProps extends IPosition {
|
|||
// whether this context menu should be focus managed. If false it must handle itself
|
||||
managed?: boolean;
|
||||
wrapperClassName?: string;
|
||||
menuClassName?: string;
|
||||
|
||||
// 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.
|
||||
|
@ -319,7 +320,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
|
||||
'mx_ContextualMenu_rightAligned': this.props.rightAligned === true,
|
||||
'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true,
|
||||
});
|
||||
}, this.props.menuClassName);
|
||||
|
||||
const menuStyle: CSSProperties = {};
|
||||
if (props.menuWidth) {
|
||||
|
|
|
@ -45,6 +45,7 @@ interface IProps {
|
|||
onClick?: React.MouseEventHandler;
|
||||
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
|
||||
className?: string;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
const calculateUrls = (url, urls, lowBandwidth) => {
|
||||
|
|
|
@ -43,6 +43,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
|
|||
title?: string;
|
||||
style?: any;
|
||||
forceHistorical?: boolean; // true to deny `feature_use_only_current_profiles` usage. Default false.
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -124,7 +125,7 @@ export default class MemberAvatar extends React.PureComponent<IProps, IState> {
|
|||
<BaseAvatar
|
||||
{...otherProps}
|
||||
name={this.state.name}
|
||||
title={this.state.title}
|
||||
title={this.props.hideTitle ? undefined : this.state.title}
|
||||
idName={userId}
|
||||
url={this.state.imageUrl}
|
||||
onClick={onClick}
|
||||
|
|
|
@ -32,6 +32,8 @@ export enum Alignment {
|
|||
Top, // Centered
|
||||
Bottom, // Centered
|
||||
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 {
|
||||
|
@ -149,6 +151,16 @@ export default class Tooltip extends React.Component<ITooltipProps> {
|
|||
style.top = baseTop + parentBox.height - 50;
|
||||
style.left = horizontalCenter;
|
||||
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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
254
src/components/views/rooms/ReadReceiptGroup.tsx
Normal file
254
src/components/views/rooms/ReadReceiptGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -19,15 +19,13 @@ import React, { createRef, RefObject } from 'react';
|
|||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatDate } from '../../../DateUtils';
|
||||
import NodeAnimator from "../../../NodeAnimator";
|
||||
import { toPx } from "../../../utils/units";
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
|
||||
export interface IReadReceiptInfo {
|
||||
top?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
parent?: Element;
|
||||
}
|
||||
|
||||
|
@ -40,7 +38,7 @@ interface IProps {
|
|||
|
||||
// number of pixels to offset the avatar from the right of its parent;
|
||||
// typically a negative value.
|
||||
leftOffset?: number;
|
||||
offset: number;
|
||||
|
||||
// true to hide the avatar (it will still be animated)
|
||||
hidden?: boolean;
|
||||
|
@ -56,9 +54,6 @@ interface IProps {
|
|||
// are being unmounted.
|
||||
checkUnmounting?: () => boolean;
|
||||
|
||||
// callback for clicks on this RR
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
// Timestamp when the receipt was read
|
||||
timestamp?: number;
|
||||
|
||||
|
@ -73,16 +68,12 @@ interface IState {
|
|||
|
||||
interface IReadReceiptMarkerStyle {
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
export default class ReadReceiptMarker extends React.PureComponent<IProps, IState> {
|
||||
private avatar: React.RefObject<HTMLDivElement | HTMLImageElement | HTMLSpanElement> = createRef();
|
||||
|
||||
static defaultProps = {
|
||||
leftOffset: 0,
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
|
@ -112,7 +103,7 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
|
|||
|
||||
const avatarNode = this.avatar.current;
|
||||
rrInfo.top = avatarNode.offsetTop;
|
||||
rrInfo.left = avatarNode.offsetLeft;
|
||||
rrInfo.right = avatarNode.getBoundingClientRect().right - avatarNode.offsetParent.getBoundingClientRect().right;
|
||||
rrInfo.parent = avatarNode.offsetParent;
|
||||
}
|
||||
|
||||
|
@ -125,9 +116,9 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
|
|||
}
|
||||
|
||||
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;
|
||||
if (differentLeftOffset || visibilityChanged) {
|
||||
if (differentOffset || visibilityChanged) {
|
||||
this.animateMarker();
|
||||
}
|
||||
}
|
||||
|
@ -157,13 +148,13 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
|
|||
|
||||
const startStyles = [];
|
||||
|
||||
if (oldInfo && oldInfo.left) {
|
||||
if (oldInfo && oldInfo.right) {
|
||||
// start at the old height and in the old h pos
|
||||
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({
|
||||
suppressDisplay: false,
|
||||
|
@ -177,29 +168,10 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
|
|||
}
|
||||
|
||||
const style = {
|
||||
left: toPx(this.props.leftOffset),
|
||||
right: toPx(this.props.offset),
|
||||
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 (
|
||||
<NodeAnimator startStyles={this.state.startStyles}>
|
||||
<MemberAvatar
|
||||
|
@ -211,9 +183,9 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
|
|||
height={14}
|
||||
resizeMethod="crop"
|
||||
style={style}
|
||||
title={title}
|
||||
onClick={this.props.onClick}
|
||||
inputRef={this.avatar as RefObject<HTMLImageElement>}
|
||||
hideTitle
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</NodeAnimator>
|
||||
);
|
||||
|
|
|
@ -1763,8 +1763,8 @@
|
|||
"Preview": "Preview",
|
||||
"View": "View",
|
||||
"Join": "Join",
|
||||
"Seen by %(userName)s at %(dateTime)s": "Seen by %(userName)s at %(dateTime)s",
|
||||
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "Seen by %(displayName)s (%(userName)s) at %(dateTime)s",
|
||||
"Seen by %(count)s people|other": "Seen by %(count)s people",
|
||||
"Seen by %(count)s people|one": "Seen by %(count)s person",
|
||||
"Recently viewed": "Recently viewed",
|
||||
"Replying": "Replying",
|
||||
"Room %(name)s": "Room %(name)s",
|
||||
|
|
24
src/utils/useTooltip.tsx
Normal file
24
src/utils/useTooltip.tsx
Normal 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];
|
||||
}
|
Loading…
Reference in a new issue