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
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>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue