element-web/src/components/structures/ThreadPanel.tsx

248 lines
9.8 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Optional } from "matrix-events-sdk";
import React, { useContext, useEffect, useRef, useState } from "react";
import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix";
import { IconButton, Tooltip } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads";
import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg";
import BaseCard from "../views/right_panel/BaseCard";
import ResizeNotifier from "../../utils/ResizeNotifier";
import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext";
import { _t } from "../../languageHandler";
import { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from "./ContextMenu";
import RoomContext, { TimelineRenderingType, useRoomContext } from "../../contexts/RoomContext";
import TimelinePanel from "./TimelinePanel";
import { Layout } from "../../settings/enums/Layout";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Measured from "../views/elements/Measured";
import PosthogTrackers from "../../PosthogTrackers";
import { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner";
import { clearRoomNotification } from "../../utils/notifications";
import EmptyState from "../views/right_panel/EmptyState";
interface IProps {
roomId: string;
onClose: () => void;
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
}
export enum ThreadFilterType {
"My",
"All",
}
type ThreadPanelHeaderOption = {
label: string;
description: string;
key: ThreadFilterType;
};
export const ThreadPanelHeaderFilterOptionItem: React.FC<
ThreadPanelHeaderOption & {
onClick: () => void;
isSelected: boolean;
}
> = ({ label, description, onClick, isSelected }) => {
return (
<MenuItemRadio active={isSelected} className="mx_ThreadPanel_Header_FilterOptionItem" onClick={onClick}>
<span>{label}</span>
<span>{description}</span>
</MenuItemRadio>
);
};
export const ThreadPanelHeader: React.FC<{
filterOption: ThreadFilterType;
setFilterOption: (filterOption: ThreadFilterType) => void;
}> = ({ filterOption, setFilterOption }) => {
const mxClient = useMatrixClientContext();
const roomContext = useRoomContext();
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
const options: readonly ThreadPanelHeaderOption[] = [
{
label: _t("threads|all_threads"),
description: _t("threads|all_threads_description"),
key: ThreadFilterType.All,
},
{
label: _t("threads|my_threads"),
description: _t("threads|my_threads_description"),
key: ThreadFilterType.My,
},
];
const value = options.find((option) => option.key === filterOption);
const contextMenuOptions = options.map((opt) => (
<ThreadPanelHeaderFilterOptionItem
key={opt.key}
label={opt.label}
description={opt.description}
onClick={() => {
setFilterOption(opt.key);
closeMenu();
}}
isSelected={opt === value}
/>
));
const contextMenu = menuDisplayed ? (
<ContextMenu
top={108}
right={33}
onFinished={closeMenu}
chevronFace={ChevronFace.Top}
wrapperClassName="mx_BaseCard_header_title"
>
{contextMenuOptions}
</ContextMenu>
) : null;
const onMarkAllThreadsReadClick = React.useCallback(
(e: React.MouseEvent) => {
PosthogTrackers.trackInteraction("WebThreadsMarkAllReadButton", e);
if (!roomContext.room) {
logger.error("No room in context to mark all threads read");
return;
}
// This actually clears all room notifications by sending an unthreaded read receipt.
// We'd have to loop over all unread threads (pagninating back to find any we don't
// know about yet) and send threaded receipts for all of them... or implement a
// specific API for it. In practice, the user will have to be viewing the room to
// see this button, so will have marked the room itself read anyway.
clearRoomNotification(roomContext.room, mxClient).catch((e) => {
logger.error("Failed to mark all threads read", e);
});
},
[roomContext.room, mxClient],
);
return (
<div className="mx_BaseCard_header_title">
<Tooltip label={_t("threads|mark_all_read")}>
<IconButton onClick={onMarkAllThreadsReadClick} aria-label={_t("threads|mark_all_read")} size="24px">
<MarkAllThreadsReadIcon />
</IconButton>
</Tooltip>
<div className="mx_ThreadPanel_vertical_separator" />
<ContextMenuButton
className="mx_ThreadPanel_dropdown"
ref={button}
isExpanded={menuDisplayed}
onClick={(ev: ButtonEvent) => {
openMenu();
PosthogTrackers.trackInteraction("WebRightPanelThreadPanelFilterDropdown", ev);
}}
>
{`${_t("threads|show_thread_filter")} ${value?.label}`}
</ContextMenuButton>
{contextMenu}
</div>
);
};
const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) => {
const mxClient = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const timelinePanel = useRef<TimelinePanel | null>(null);
const card = useRef<HTMLDivElement | null>(null);
const closeButonRef = useRef<HTMLButtonElement | null>(null);
const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All);
const [room, setRoom] = useState<Room | null>(null);
const [narrow, setNarrow] = useState<boolean>(false);
const timelineSet: Optional<EventTimelineSet> =
filterOption === ThreadFilterType.My ? room?.threadsTimelineSets[1] : room?.threadsTimelineSets[0];
const hasThreads = Boolean(room?.threadsTimelineSets?.[0]?.getLiveTimeline()?.getEvents()?.length);
useEffect(() => {
const room = mxClient.getRoom(roomId);
room
?.createThreadsTimelineSets()
.then(() => room.fetchRoomThreads())
.then(() => {
setFilterOption(ThreadFilterType.All);
setRoom(room);
});
}, [mxClient, roomId]);
useEffect(() => {
if (timelineSet && !Thread.hasServerSideSupport) {
timelinePanel.current?.refreshTimeline();
}
}, [timelineSet, timelinePanel]);
return (
<RoomContext.Provider
value={{
...roomContext,
timelineRenderingType: TimelineRenderingType.ThreadsList,
showHiddenEvents: true,
narrow,
}}
>
<BaseCard
header={
hasThreads && <ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />
}
id="thread-panel"
className="mx_ThreadPanel"
ariaLabelledBy="thread-panel-tab"
role="tabpanel"
onClose={onClose}
withoutScrollContainer={true}
ref={card}
closeButtonRef={closeButonRef}
>
{card.current && <Measured sensor={card.current} onMeasurement={setNarrow} />}
{timelineSet ? (
<TimelinePanel
key={filterOption + ":" + (timelineSet.getFilter()?.filterId ?? roomId)}
ref={timelinePanel}
showReadReceipts={false} // No RR support in thread's list
manageReadReceipts={false} // No RR support in thread's list
manageReadMarkers={false} // No RM support in thread's list
sendReadReceiptOnLoad={false} // No RR support in thread's list
timelineSet={timelineSet}
showUrlPreview={false} // No URL previews at the threads list level
empty={
<EmptyState
Icon={ThreadsIcon}
title={_t("threads|empty_title")}
description={_t("threads|empty_description", {
replyInThread: _t("action|reply_in_thread"),
})}
/>
}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
hidden={false}
showReactions={false}
className="mx_RoomView_messagePanel"
membersLoaded={true}
permalinkCreator={permalinkCreator}
disableGrouping={true}
/>
) : (
<div className="mx_AutoHideScrollbar">
<Spinner />
</div>
)}
</BaseCard>
</RoomContext.Provider>
);
};
export default ThreadPanel;