Files
ThreadNet-Web/apps/web/src/components/views/rooms/ReadReceiptGroup.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

264 lines
9.6 KiB
TypeScript
Raw Normal View History

2022-04-22 17:09:44 +02:00
/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
2022-04-22 17:09:44 +02:00
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2022-04-22 17:09:44 +02:00
*/
import React, { type JSX, type PropsWithChildren } from "react";
2025-02-05 13:25:06 +00:00
import { type User } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web";
import { AutoHideScrollbar } from "@element-hq/web-shared-components";
2022-04-22 17:09:44 +02:00
2026-06-30 13:49:28 +01:00
import ReadReceiptMarker, { type IReadReceiptPosition, READ_AVATAR_SIZE } from "./ReadReceiptMarker";
2025-02-05 13:25:06 +00:00
import { type IReadReceiptProps } from "./EventTile";
2022-04-22 17:09:44 +02:00
import AccessibleButton from "../elements/AccessibleButton";
import MemberAvatar from "../avatars/MemberAvatar";
import { formatDate } from "../../../DateUtils";
import { Action } from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
import ContextMenu, { aboveLeftOf, MenuItem, useContextMenu } from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import { formatList } from "../../../utils/FormattingUtils";
2022-04-22 17:09:44 +02:00
// #20547 Design specified that we should show the three latest read receipts
const MAX_READ_AVATARS_PLUS_N = 3;
// #21935 If weve got just 4, dont show +1, just show all of them
const MAX_READ_AVATARS = MAX_READ_AVATARS_PLUS_N + 1;
2022-04-22 17:09:44 +02:00
const READ_AVATAR_OFFSET = 10;
interface Props {
readReceipts: IReadReceiptProps[];
2024-09-03 12:59:30 +01:00
readReceiptMap: { [userId: string]: IReadReceiptPosition };
checkUnmounting?: () => boolean;
2022-04-22 17:09:44 +02:00
suppressAnimation: boolean;
isTwelveHour?: boolean;
2022-04-22 17:09:44 +02:00
}
interface IAvatarPosition {
hidden: boolean;
position: number;
}
export function determineAvatarPosition(index: number, max: number): IAvatarPosition {
if (index < max) {
return {
hidden: false,
position: index,
};
2022-04-22 17:09:44 +02:00
} else {
return {
hidden: true,
position: 0,
};
2022-04-22 17:09:44 +02:00
}
}
export function readReceiptTooltip(members: string[], maxAvatars: number): string | undefined {
return formatList(members, maxAvatars);
}
2022-04-22 17:09:44 +02:00
export function ReadReceiptGroup({
readReceipts,
readReceiptMap,
checkUnmounting,
suppressAnimation,
isTwelveHour,
}: Props): JSX.Element {
2022-04-22 17:09:44 +02:00
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
// If we are above MAX_READ_AVATARS, well have to remove a few to have space for the +n count.
const hasMore = readReceipts.length > MAX_READ_AVATARS;
const maxAvatars = hasMore ? MAX_READ_AVATARS_PLUS_N : MAX_READ_AVATARS;
const tooltipMembers: string[] = readReceipts.map((it) => it.roomMember?.name ?? it.userId);
const tooltipText = readReceiptTooltip(tooltipMembers, maxAvatars);
2022-04-22 17:09:44 +02:00
// 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, maxAvatars);
2022-04-22 17:09:44 +02:00
const userId = receipt.userId;
2024-09-03 12:59:30 +01:00
let readReceiptPosition: IReadReceiptPosition | undefined;
2022-04-22 17:09:44 +02:00
if (readReceiptMap) {
2024-09-03 12:59:30 +01:00
readReceiptPosition = readReceiptMap[userId];
if (!readReceiptPosition) {
readReceiptPosition = {};
// eslint-disable-next-line react-compiler/react-compiler
2024-09-03 12:59:30 +01:00
readReceiptMap[userId] = readReceiptPosition;
2022-12-12 12:24:14 +01:00
}
2022-04-22 17:09:44 +02:00
}
return (
<ReadReceiptMarker
key={userId}
member={receipt.roomMember}
fallbackUserId={userId}
offset={position * READ_AVATAR_OFFSET}
hidden={hidden}
2024-09-03 12:59:30 +01:00
readReceiptPosition={readReceiptPosition}
2022-04-22 17:09:44 +02:00
checkUnmounting={checkUnmounting}
suppressAnimation={suppressAnimation}
/>
);
})
.reverse();
2022-04-22 17:09:44 +02:00
let remText: JSX.Element | undefined;
const remainder = readReceipts.length - maxAvatars;
2022-04-22 17:09:44 +02:00
if (remainder > 0) {
remText = (
<span className="mx_ReadReceiptGroup_remainder" aria-live="off">
+{remainder}
</span>
);
}
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && button.current) {
2022-04-22 17:09:44 +02:00
const buttonRect = button.current.getBoundingClientRect();
contextMenu = (
<ContextMenu menuClassName="mx_ReadReceiptGroup_popup" onFinished={closeMenu} {...aboveLeftOf(buttonRect)}>
<AutoHideScrollbar className="mx_AutoHideScrollbar">
2022-04-22 17:09:44 +02:00
<SectionHeader className="mx_ReadReceiptGroup_title">
{_t("timeline|read_receipt_title", { count: readReceipts.length })}
2022-04-22 17:09:44 +02:00
</SectionHeader>
{readReceipts.map((receipt) => (
<ReadReceiptPerson
key={receipt.userId}
{...receipt}
isTwelveHour={isTwelveHour}
onAfterClick={closeMenu}
/>
))}
</AutoHideScrollbar>
</ContextMenu>
);
}
return (
<div className="mx_EventTile_msgOption">
<Tooltip
label={_t("timeline|read_receipt_title", { count: readReceipts.length })}
caption={tooltipText}
placement="top-end"
>
<div className="mx_ReadReceiptGroup" role="group" aria-label={_t("timeline|read_receipts_label")}>
<AccessibleButton
className="mx_ReadReceiptGroup_button"
ref={button}
aria-label={tooltipText}
aria-haspopup="true"
onClick={openMenu}
2022-04-22 17:09:44 +02:00
>
{remText}
<span
className="mx_ReadReceiptGroup_container"
style={{
width:
Math.min(maxAvatars, readReceipts.length) * READ_AVATAR_OFFSET +
READ_AVATAR_SIZE -
READ_AVATAR_OFFSET,
}}
>
{avatars}
</span>
</AccessibleButton>
{contextMenu}
</div>
</Tooltip>
2022-04-22 17:09:44 +02:00
</div>
);
}
interface ReadReceiptPersonProps extends IReadReceiptProps {
isTwelveHour?: boolean;
2022-04-22 17:09:44 +02:00
onAfterClick?: () => void;
}
2024-05-22 15:07:57 +02:00
// Export for testing
export function ReadReceiptPerson({
userId,
roomMember,
ts,
isTwelveHour,
onAfterClick,
}: ReadReceiptPersonProps): JSX.Element {
2022-04-22 17:09:44 +02:00
return (
<Tooltip
description={roomMember?.rawDisplayName ?? userId}
caption={userId}
placement="top"
isTriggerInteractive={false}
>
<MenuItem
className="mx_ReadReceiptGroup_person"
onClick={() => {
dis.dispatch({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants.
// The ViewUser action leads to the RightPanelStore, and RightPanelStoreIPanelState defines the
// member property of IRightPanelCardState as `RoomMember | User`, so were fine for now, but we
// should definitely clean this up later
member: roomMember ?? ({ userId } as User),
push: false,
});
onAfterClick?.();
}}
>
<MemberAvatar
member={roomMember}
fallbackUserId={userId}
size="24px"
aria-hidden="true"
aria-live="off"
resizeMethod="crop"
hideTitle
/>
<div className="mx_ReadReceiptGroup_name">
<p>{roomMember?.name ?? userId}</p>
<p className="mx_ReadReceiptGroup_secondary">{formatDate(new Date(ts), isTwelveHour)}</p>
</div>
</MenuItem>
</Tooltip>
2022-04-22 17:09:44 +02:00
);
}
interface ISectionHeaderProps {
className?: string;
}
function SectionHeader({ className, children }: PropsWithChildren<ISectionHeaderProps>): JSX.Element {
const [onFocus, , ref] = useRovingTabIndex<HTMLHeadingElement>();
2022-04-22 17:09:44 +02:00
return (
<h3 className={className} role="menuitem" onFocus={onFocus} tabIndex={-1} ref={ref}>
{children}
</h3>
);
}