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/_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";
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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 { 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}
|
||||||
|
/>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
/>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
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 { 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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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
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