2022-04-22 17:09:44 +02:00
|
|
|
|
/*
|
|
|
|
|
|
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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2023-12-20 10:58:24 +00:00
|
|
|
|
import React, { PropsWithChildren } from "react";
|
2022-04-26 12:35:05 +02:00
|
|
|
|
import { User } from "matrix-js-sdk/src/matrix";
|
2024-05-21 18:17:31 +02:00
|
|
|
|
import { Tooltip } from "@vector-im/compound-web";
|
2022-04-22 17:09:44 +02:00
|
|
|
|
|
|
|
|
|
|
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 { 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";
|
2023-10-03 19:17:26 +01:00
|
|
|
|
import { formatList } from "../../../utils/FormattingUtils";
|
2022-04-22 17:09:44 +02:00
|
|
|
|
|
2022-04-27 14:27:29 +02:00
|
|
|
|
// #20547 Design specified that we should show the three latest read receipts
|
|
|
|
|
|
const MAX_READ_AVATARS_PLUS_N = 3;
|
|
|
|
|
|
// #21935 If we’ve got just 4, don’t 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;
|
|
|
|
|
|
export const READ_AVATAR_SIZE = 16;
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
readReceipts: IReadReceiptProps[];
|
|
|
|
|
|
readReceiptMap: { [userId: string]: IReadReceiptInfo };
|
2023-03-13 15:07:20 +00:00
|
|
|
|
checkUnmounting?: () => boolean;
|
2022-04-22 17:09:44 +02:00
|
|
|
|
suppressAnimation: boolean;
|
2023-03-13 15:07:20 +00:00
|
|
|
|
isTwelveHour?: boolean;
|
2022-04-22 17:09:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2022-04-27 14:27:29 +02:00
|
|
|
|
interface IAvatarPosition {
|
|
|
|
|
|
hidden: boolean;
|
|
|
|
|
|
position: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2022-05-05 14:10:48 +02:00
|
|
|
|
export function determineAvatarPosition(index: number, max: number): IAvatarPosition {
|
2022-05-02 11:46:11 +02:00
|
|
|
|
if (index < max) {
|
2022-04-27 14:27:29 +02:00
|
|
|
|
return {
|
|
|
|
|
|
hidden: false,
|
2022-05-05 14:10:48 +02:00
|
|
|
|
position: index,
|
2022-04-27 14:27:29 +02:00
|
|
|
|
};
|
2022-04-22 17:09:44 +02:00
|
|
|
|
} else {
|
2022-04-27 14:27:29 +02:00
|
|
|
|
return {
|
|
|
|
|
|
hidden: true,
|
|
|
|
|
|
position: 0,
|
|
|
|
|
|
};
|
2022-04-22 17:09:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-10-03 19:17:26 +01:00
|
|
|
|
export function readReceiptTooltip(members: string[], maxAvatars: number): string | undefined {
|
|
|
|
|
|
return formatList(members, maxAvatars);
|
2022-05-02 11:46:11 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2022-04-22 17:09:44 +02:00
|
|
|
|
export function ReadReceiptGroup({
|
|
|
|
|
|
readReceipts,
|
|
|
|
|
|
readReceiptMap,
|
|
|
|
|
|
checkUnmounting,
|
|
|
|
|
|
suppressAnimation,
|
|
|
|
|
|
isTwelveHour,
|
2023-01-12 13:25:14 +00:00
|
|
|
|
}: Props): JSX.Element {
|
2022-04-22 17:09:44 +02:00
|
|
|
|
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
2022-05-02 11:46:11 +02:00
|
|
|
|
|
|
|
|
|
|
// If we are above MAX_READ_AVATARS, we’ll 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;
|
|
|
|
|
|
|
2023-10-03 19:17:26 +01:00
|
|
|
|
const tooltipMembers: string[] = readReceipts.map((it) => it.roomMember?.name ?? it.userId);
|
|
|
|
|
|
const tooltipText = readReceiptTooltip(tooltipMembers, maxAvatars);
|
2022-05-02 11:46:11 +02:00
|
|
|
|
|
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) => {
|
2022-05-05 14:10:48 +02:00
|
|
|
|
const { hidden, position } = determineAvatarPosition(index, maxAvatars);
|
2022-04-22 17:09:44 +02:00
|
|
|
|
|
|
|
|
|
|
const userId = receipt.userId;
|
2023-02-24 15:28:40 +00:00
|
|
|
|
let readReceiptInfo: IReadReceiptInfo | undefined;
|
2022-04-22 17:09:44 +02:00
|
|
|
|
|
|
|
|
|
|
if (readReceiptMap) {
|
|
|
|
|
|
readReceiptInfo = readReceiptMap[userId];
|
|
|
|
|
|
if (!readReceiptInfo) {
|
|
|
|
|
|
readReceiptInfo = {};
|
|
|
|
|
|
readReceiptMap[userId] = readReceiptInfo;
|
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}
|
|
|
|
|
|
readReceiptInfo={readReceiptInfo}
|
|
|
|
|
|
checkUnmounting={checkUnmounting}
|
|
|
|
|
|
suppressAnimation={suppressAnimation}
|
|
|
|
|
|
timestamp={receipt.ts}
|
|
|
|
|
|
showTwelveHour={isTwelveHour}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2022-05-02 11:46:11 +02:00
|
|
|
|
})
|
|
|
|
|
|
.reverse();
|
2022-04-22 17:09:44 +02:00
|
|
|
|
|
2023-02-24 15:28:40 +00:00
|
|
|
|
let remText: JSX.Element | undefined;
|
2022-04-27 14:27:29 +02:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-03-13 15:07:20 +00:00
|
|
|
|
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>
|
|
|
|
|
|
<SectionHeader className="mx_ReadReceiptGroup_title">
|
2023-09-27 17:15:22 +01:00
|
|
|
|
{_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">
|
2024-05-21 18:17:31 +02:00
|
|
|
|
<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
|
|
|
|
>
|
2024-05-21 18:17:31 +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 {
|
2023-03-13 15:07:20 +00:00
|
|
|
|
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({
|
2023-01-12 13:25:14 +00:00
|
|
|
|
userId,
|
|
|
|
|
|
roomMember,
|
|
|
|
|
|
ts,
|
|
|
|
|
|
isTwelveHour,
|
|
|
|
|
|
onAfterClick,
|
|
|
|
|
|
}: ReadReceiptPersonProps): JSX.Element {
|
2022-04-22 17:09:44 +02:00
|
|
|
|
return (
|
2024-05-22 09:29:54 +02:00
|
|
|
|
<Tooltip label={roomMember?.rawDisplayName ?? userId} caption={userId} placement="top">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<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 we’re 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>
|
2022-04-22 17:09:44 +02:00
|
|
|
|
</div>
|
2024-05-22 09:29:54 +02:00
|
|
|
|
</Tooltip>
|
2022-04-22 17:09:44 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ISectionHeaderProps {
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
|
function SectionHeader({ className, children }: PropsWithChildren<ISectionHeaderProps>): JSX.Element {
|
2023-12-20 10:58:24 +00:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|