Refactor MessageActionBar using MVVM and move to shared-components (#32784)
* Refactor MessageActionBar into MVVM ActionBarView * Adding tooltips for menu items and correct i18n strings * Layout changes * Renaming some properties * Rename property * Create a first version of the view model and refactor media visibility logic * Refactor view to take options and rections menu as optional properties * Cleaner interface between view and view model * Refactor view properties and replace Menu and MenuItem * Bugfixes and switching to ActionBarView instead of MessageActionBar in element-web * Avoid creating view models and render toolbar until it is actually shown * Added unit and playwright tests and documented the view * Added view model unit tests and updated snapshots of dependant tests * Remove unused components and unnecessary css * Remove unused language tags * Fix for handling join-rules correctly * Prettier * Add handling of stale view model in async calls * Prettier * Split the element-web css into two different. One for legacy components and one for the ActionBarView * Missing variables used for linting * Fix for showing ActionBarView when using keyboard for navigation * Handle visibility on context menu closing * ThreadPanel uses the ActionBarView so restore css rule * Fix for visibility of the ActionBarView in Thread panel * Fix for ActionBarVuew visibility when closing right-click context menu and not still hovering * Add roving index to function as a toolbar * Adjust the RoomView test to send hover to the EventTile instead of the message text * Fix SonarCloud issues * Fix for SonarCloud issue * Merge fix * Rename mx_LegacyActionBar to mx_ThreadActionBar * Added documentation and simplified join rules * Generalize the ActionBarView and move logic to view model * Add the four new buttons to the ActionBarView * Update view model and tests to use the updated ActionBarView * Refactor element-web to use ActionBarView * Clean up styling in element-web * Clean up and updating snaps and screenshots * Added unit-tests for better coverage * Moving ActionBarView to the correct folder in shared components * Update snaps in element-web * Better documentation in stories * Merge fixes * Updates after review comments * Review comment fixes * Added documentation to view models and updated snaps * Hide button had the wrong label * Replace createRef with useRef
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import React, { type ReactElement, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useDownloadMedia } from "../../../hooks/useDownloadMedia";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
||||
// XXX: It can take a cycle or two for the MessageActionBar to have all the props/setup
|
||||
// required to get us a MediaEventHelper, so we use a getter function instead to prod for
|
||||
// one.
|
||||
mediaEventHelperGet: () => MediaEventHelper | undefined;
|
||||
}
|
||||
|
||||
function useButtonTitle(loading: boolean, isEncrypted: boolean): string {
|
||||
if (!loading) return _t("action|download");
|
||||
|
||||
return isEncrypted ? _t("timeline|download_action_decrypting") : _t("timeline|download_action_downloading");
|
||||
}
|
||||
|
||||
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
|
||||
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
|
||||
const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
|
||||
const fileName = mediaEventHelper?.fileName;
|
||||
|
||||
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
|
||||
|
||||
const buttonTitle = useButtonTitle(loading, mediaEventHelper?.media.isEncrypted ?? false);
|
||||
|
||||
if (!canDownload) return null;
|
||||
|
||||
const spinner = loading ? <Spinner size={18} /> : undefined;
|
||||
const classes = classNames({
|
||||
mx_MessageActionBar_iconButton: true,
|
||||
mx_MessageActionBar_downloadButton: true,
|
||||
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
|
||||
});
|
||||
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
className={classes}
|
||||
title={buttonTitle}
|
||||
onClick={download}
|
||||
disabled={loading}
|
||||
placement="left"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{spinner}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
@@ -6,17 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, createRef } from "react";
|
||||
import React, { createRef } from "react";
|
||||
import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import { EventContentBodyView } from "@element-hq/web-shared-components";
|
||||
import { ActionBarView, EventContentBodyView } from "@element-hq/web-shared-components";
|
||||
|
||||
import { EditHistoryActionBarViewModel } from "../../../viewmodels/message-body/EditHistoryActionBarViewModel";
|
||||
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
|
||||
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
|
||||
import { formatTime } from "../../../DateUtils";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
|
||||
import ViewSource from "../../structures/ViewSource";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
@@ -47,6 +46,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
|
||||
private content = createRef<HTMLDivElement>();
|
||||
private EventContentBodyViewModel: EventContentBodyViewModel;
|
||||
private editHistoryActionBarViewModel: EditHistoryActionBarViewModel;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
@@ -72,6 +72,13 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
linkify: true,
|
||||
client: cli,
|
||||
});
|
||||
|
||||
this.editHistoryActionBarViewModel = new EditHistoryActionBarViewModel({
|
||||
canRemove: !props.mxEvent.isRedacted() && !props.isBaseEvent && canRedact,
|
||||
showViewSource: SettingsStore.getValue("developerMode"),
|
||||
onRemoveClick: this.onRedactClick,
|
||||
onViewSourceClick: this.onViewSourceClick,
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps): void {
|
||||
@@ -79,6 +86,13 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
const mxEventContent = getReplacedContent(this.props.mxEvent);
|
||||
this.EventContentBodyViewModel.setEventContent(this.props.mxEvent, mxEventContent);
|
||||
}
|
||||
|
||||
this.editHistoryActionBarViewModel.setProps({
|
||||
canRemove: !this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact,
|
||||
showViewSource: SettingsStore.getValue("developerMode"),
|
||||
onRemoveClick: this.onRedactClick,
|
||||
onViewSourceClick: this.onViewSourceClick,
|
||||
});
|
||||
}
|
||||
|
||||
private onAssociatedStatusChanged = (): void => {
|
||||
@@ -116,34 +130,20 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
const event = this.props.mxEvent;
|
||||
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
|
||||
this.EventContentBodyViewModel.dispose();
|
||||
this.editHistoryActionBarViewModel.dispose();
|
||||
}
|
||||
|
||||
private renderActionBar(): React.ReactNode {
|
||||
// hide the button when already redacted
|
||||
let redactButton: JSX.Element | undefined;
|
||||
if (!this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact) {
|
||||
redactButton = <AccessibleButton onClick={this.onRedactClick}>{_t("action|remove")}</AccessibleButton>;
|
||||
}
|
||||
this.editHistoryActionBarViewModel.setProps({
|
||||
canRemove: !this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact,
|
||||
showViewSource: SettingsStore.getValue("developerMode"),
|
||||
onRemoveClick: this.onRedactClick,
|
||||
onViewSourceClick: this.onViewSourceClick,
|
||||
});
|
||||
|
||||
let viewSourceButton: JSX.Element | undefined;
|
||||
if (SettingsStore.getValue("developerMode")) {
|
||||
viewSourceButton = (
|
||||
<AccessibleButton onClick={this.onViewSourceClick}>{_t("action|view_source")}</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (!redactButton && !viewSourceButton) {
|
||||
// Hide the empty MessageActionBar
|
||||
return null;
|
||||
} else {
|
||||
// disabled remove button when not allowed
|
||||
return (
|
||||
<div className="mx_MessageActionBar">
|
||||
{redactButton}
|
||||
{viewSourceButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ActionBarView vm={this.editHistoryActionBarViewModel} className="mx_ThreadActionBar mx_HistoryActionBar" />
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import React from "react";
|
||||
import { VisibilityOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
|
||||
interface IProps {
|
||||
/**
|
||||
* Matrix event that this action applies to.
|
||||
*/
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick action button for marking a media event as hidden.
|
||||
*/
|
||||
export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => {
|
||||
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent);
|
||||
|
||||
if (!mediaIsVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton "
|
||||
title={_t("action|hide")}
|
||||
onClick={() => setVisible(false)}
|
||||
placement="left"
|
||||
>
|
||||
<VisibilityOffIcon />
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
@@ -1,601 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type ReactElement, useCallback, useContext, useEffect } from "react";
|
||||
import {
|
||||
EventStatus,
|
||||
type MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
MsgType,
|
||||
RelationType,
|
||||
M_BEACON_INFO,
|
||||
EventTimeline,
|
||||
RoomStateEvent,
|
||||
EventType,
|
||||
type Relations,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
PinIcon,
|
||||
UnpinIcon,
|
||||
OverflowHorizontalIcon,
|
||||
ReplyIcon,
|
||||
DeleteIcon,
|
||||
RestartIcon,
|
||||
ThreadsIcon,
|
||||
EditIcon,
|
||||
ReactionAddIcon,
|
||||
ExpandIcon,
|
||||
CollapseIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
|
||||
import { isContentActionable, canEditContent, editEvent, canCancel } from "../../../utils/EventUtils";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
import { RovingAccessibleButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
|
||||
import MessageContextMenu from "../context_menus/MessageContextMenu";
|
||||
import Resend from "../../../Resend";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import DownloadActionButton from "./DownloadActionButton";
|
||||
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import type ReplyChain from "../elements/ReplyChain";
|
||||
import ReactionPicker from "../emojipicker/ReactionPicker";
|
||||
import { CardContext } from "../right_panel/context";
|
||||
import { shouldDisplayReply } from "../../../utils/Reply";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||
import { type GetRelationsForEvent, type IEventTileType } from "../rooms/EventTile";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
import PosthogTrackers from "../../../PosthogTrackers.ts";
|
||||
import { HideActionButton } from "./HideActionButton.tsx";
|
||||
|
||||
interface IOptionsButtonProps {
|
||||
mxEvent: MatrixEvent;
|
||||
getTile: () => IEventTileType | null;
|
||||
getReplyChain: () => ReplyChain | null;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
onFocusChange: (menuDisplayed: boolean) => void;
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
}
|
||||
|
||||
const OptionsButton: React.FC<IOptionsButtonProps> = ({
|
||||
mxEvent,
|
||||
getTile,
|
||||
getReplyChain,
|
||||
permalinkCreator,
|
||||
onFocusChange,
|
||||
getRelationsForEvent,
|
||||
}) => {
|
||||
const [onFocus, isActive, buttonRefCallback, buttonRef] = useRovingTabIndex();
|
||||
const [menuDisplayed, , openMenu, closeMenu] = useContextMenu(buttonRef);
|
||||
useEffect(() => {
|
||||
onFocusChange(menuDisplayed);
|
||||
}, [onFocusChange, menuDisplayed]);
|
||||
|
||||
const onOptionsClick = useCallback(
|
||||
(e: ButtonEvent): void => {
|
||||
// Don't open the regular browser or our context menu on right-click
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMenu();
|
||||
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
|
||||
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the
|
||||
// position in the page even when someone is clicking around.
|
||||
onFocus();
|
||||
},
|
||||
[openMenu, onFocus],
|
||||
);
|
||||
|
||||
let contextMenu: ReactElement | undefined;
|
||||
if (menuDisplayed && buttonRef.current) {
|
||||
const tile = getTile?.();
|
||||
const replyChain = getReplyChain();
|
||||
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||
contextMenu = (
|
||||
<MessageContextMenu
|
||||
{...aboveLeftOf(buttonRect)}
|
||||
mxEvent={mxEvent}
|
||||
permalinkCreator={permalinkCreator}
|
||||
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
|
||||
collapseReplyChain={replyChain?.canCollapse() ? replyChain.collapse : undefined}
|
||||
onFinished={closeMenu}
|
||||
getRelationsForEvent={getRelationsForEvent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
|
||||
title={_t("common|options")}
|
||||
onClick={onOptionsClick}
|
||||
onContextMenu={onOptionsClick}
|
||||
isExpanded={menuDisplayed}
|
||||
ref={buttonRefCallback}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
placement="top"
|
||||
>
|
||||
<OverflowHorizontalIcon />
|
||||
</ContextMenuTooltipButton>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
interface IReactButtonProps {
|
||||
mxEvent: MatrixEvent;
|
||||
reactions?: Relations | null | undefined;
|
||||
onFocusChange: (menuDisplayed: boolean) => void;
|
||||
}
|
||||
|
||||
const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusChange }) => {
|
||||
const [onFocus, isActive, buttonRefCallback, buttonRef] = useRovingTabIndex();
|
||||
const [menuDisplayed, , openMenu, closeMenu] = useContextMenu(buttonRef);
|
||||
useEffect(() => {
|
||||
onFocusChange(menuDisplayed);
|
||||
}, [onFocusChange, menuDisplayed]);
|
||||
|
||||
let contextMenu: JSX.Element | undefined;
|
||||
if (menuDisplayed && buttonRef.current) {
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||
contextMenu = (
|
||||
<ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false} focusLock>
|
||||
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: ButtonEvent) => {
|
||||
// Don't open the regular browser or our context menu on right-click
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
openMenu();
|
||||
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
|
||||
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the
|
||||
// position in the page even when someone is clicking around.
|
||||
onFocus();
|
||||
},
|
||||
[openMenu, onFocus],
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
title={_t("action|react")}
|
||||
onClick={onClick}
|
||||
onContextMenu={onClick}
|
||||
isExpanded={menuDisplayed}
|
||||
ref={buttonRefCallback}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
placement="top"
|
||||
>
|
||||
<ReactionAddIcon />
|
||||
</ContextMenuTooltipButton>
|
||||
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
interface IReplyInThreadButton {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
const ReplyInThreadButton: React.FC<IReplyInThreadButton> = ({ mxEvent }) => {
|
||||
const context = useContext(CardContext);
|
||||
|
||||
const relationType = mxEvent?.getRelation()?.rel_type;
|
||||
const hasARelation = !!relationType && relationType !== RelationType.Thread;
|
||||
|
||||
const onClick = (e: ButtonEvent): void => {
|
||||
// Don't open the regular browser or our context menu on right-click
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const thread = mxEvent.getThread();
|
||||
if (thread?.rootEvent && !mxEvent.isThreadRoot) {
|
||||
defaultDispatcher.dispatch<ShowThreadPayload>({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: thread.rootEvent,
|
||||
initialEvent: mxEvent,
|
||||
scroll_into_view: true,
|
||||
highlighted: true,
|
||||
push: context.isCard,
|
||||
});
|
||||
} else {
|
||||
defaultDispatcher.dispatch<ShowThreadPayload>({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: mxEvent,
|
||||
push: context.isCard,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const title = !hasARelation ? _t("action|reply_in_thread") : _t("threads|error_start_thread_existing_relation");
|
||||
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton mx_MessageActionBar_threadButton"
|
||||
disabled={hasARelation}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
onContextMenu={onClick}
|
||||
placement="top"
|
||||
>
|
||||
<ThreadsIcon />
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
|
||||
interface IMessageActionBarProps {
|
||||
mxEvent: MatrixEvent;
|
||||
reactions?: Relations | null | undefined;
|
||||
getTile: () => IEventTileType | null;
|
||||
getReplyChain: () => ReplyChain | null;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
onFocusChange?: (menuDisplayed: boolean) => void;
|
||||
toggleThreadExpanded: () => void;
|
||||
isQuoteExpanded?: boolean;
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
}
|
||||
|
||||
export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> {
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
|
||||
this.props.mxEvent.on(MatrixEventEvent.Status, this.onSent);
|
||||
}
|
||||
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
client.decryptEventIfNeeded(this.props.mxEvent);
|
||||
|
||||
if (this.props.mxEvent.isBeingDecrypted()) {
|
||||
this.props.mxEvent.once(MatrixEventEvent.Decrypted, this.onDecrypted);
|
||||
}
|
||||
this.props.mxEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
this.context.room
|
||||
?.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.on(RoomStateEvent.Events, this.onRoomEvent);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.props.mxEvent.off(MatrixEventEvent.Status, this.onSent);
|
||||
this.props.mxEvent.off(MatrixEventEvent.Decrypted, this.onDecrypted);
|
||||
this.props.mxEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
this.context.room
|
||||
?.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.off(RoomStateEvent.Events, this.onRoomEvent);
|
||||
}
|
||||
|
||||
private onDecrypted = (): void => {
|
||||
// When an event decrypts, it is likely to change the set of available
|
||||
// actions, so we force an update to check again.
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onBeforeRedaction = (): void => {
|
||||
// When an event is redacted, we can't edit it so update the available actions.
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onRoomEvent = (event?: MatrixEvent): void => {
|
||||
// If the event is pinned or unpinned, rerender the component.
|
||||
if (!event || event.getType() !== EventType.RoomPinnedEvents) return;
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onSent = (): void => {
|
||||
// When an event is sent and echoed the possible actions change.
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onFocusChange = (focused: boolean): void => {
|
||||
this.props.onFocusChange?.(focused);
|
||||
};
|
||||
|
||||
private onReplyClick = (e: ButtonEvent): void => {
|
||||
// Don't open the regular browser or our context menu on right-click
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({
|
||||
action: "reply_to_event",
|
||||
event: this.props.mxEvent,
|
||||
context: this.context.timelineRenderingType,
|
||||
});
|
||||
};
|
||||
|
||||
private onEditClick = (e: ButtonEvent): void => {
|
||||
// Don't open the regular browser or our context menu on right-click
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
editEvent(
|
||||
MatrixClientPeg.safeGet(),
|
||||
this.props.mxEvent,
|
||||
this.context.timelineRenderingType,
|
||||
this.props.getRelationsForEvent,
|
||||
);
|
||||
};
|
||||
|
||||
private readonly forbiddenThreadHeadMsgType = [MsgType.KeyVerificationRequest];
|
||||
|
||||
private get showReplyInThreadAction(): boolean {
|
||||
const inNotThreadTimeline = this.context.timelineRenderingType !== TimelineRenderingType.Thread;
|
||||
|
||||
const isAllowedMessageType =
|
||||
!this.forbiddenThreadHeadMsgType.includes(this.props.mxEvent.getContent().msgtype as MsgType) &&
|
||||
/** forbid threads from live location shares
|
||||
* until cross-platform support
|
||||
* (PSF-1041)
|
||||
*/
|
||||
!M_BEACON_INFO.matches(this.props.mxEvent.getType());
|
||||
|
||||
return inNotThreadTimeline && isAllowedMessageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a given fn on the set of possible events to test. The first event
|
||||
* that passes the checkFn will have fn executed on it. Both functions take
|
||||
* a MatrixEvent object. If no particular conditions are needed, checkFn can
|
||||
* be null/undefined. If no functions pass the checkFn, no action will be
|
||||
* taken.
|
||||
* @param {Function} fn The execution function.
|
||||
* @param {Function} checkFn The test function.
|
||||
*/
|
||||
private runActionOnFailedEv(fn: (ev: MatrixEvent) => void, checkFn?: (ev: MatrixEvent) => boolean): void {
|
||||
if (!checkFn) checkFn = () => true;
|
||||
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const editEvent = mxEvent.replacingEvent();
|
||||
const redactEvent = mxEvent.localRedactionEvent();
|
||||
const tryOrder = [redactEvent, editEvent, mxEvent];
|
||||
for (const ev of tryOrder) {
|
||||
if (ev && checkFn(ev)) {
|
||||
fn(ev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onResendClick = (ev: ButtonEvent): void => {
|
||||
// Don't open the regular browser or our context menu on right-click
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.runActionOnFailedEv((tarEv) => Resend.resend(MatrixClientPeg.safeGet(), tarEv));
|
||||
};
|
||||
|
||||
private onCancelClick = (ev: ButtonEvent): void => {
|
||||
this.runActionOnFailedEv(
|
||||
(tarEv) => Resend.removeFromQueue(MatrixClientPeg.safeGet(), tarEv),
|
||||
(testEv) => canCancel(testEv.status),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pin or unpin the event.
|
||||
*/
|
||||
private onPinClick = async (event: ButtonEvent, isPinned: boolean): Promise<void> => {
|
||||
// Don't open the regular browser or our context menu on right-click
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline");
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const toolbarOpts: JSX.Element[] = [];
|
||||
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
|
||||
toolbarOpts.push(
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
title={_t("action|edit")}
|
||||
onClick={this.onEditClick}
|
||||
onContextMenu={this.onEditClick}
|
||||
key="edit"
|
||||
placement="top"
|
||||
>
|
||||
<EditIcon />
|
||||
</RovingAccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
PinningUtils.canPin(MatrixClientPeg.safeGet(), this.props.mxEvent) ||
|
||||
PinningUtils.canUnpin(MatrixClientPeg.safeGet(), this.props.mxEvent)
|
||||
) {
|
||||
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
toolbarOpts.push(
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
title={isPinned ? _t("action|unpin") : _t("action|pin")}
|
||||
onClick={(e: ButtonEvent) => this.onPinClick(e, isPinned)}
|
||||
onContextMenu={(e: ButtonEvent) => this.onPinClick(e, isPinned)}
|
||||
key="pin"
|
||||
placement="top"
|
||||
>
|
||||
{isPinned ? <UnpinIcon /> : <PinIcon />}
|
||||
</RovingAccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
const cancelSendingButton = (
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
title={_t("action|delete")}
|
||||
onClick={this.onCancelClick}
|
||||
onContextMenu={this.onCancelClick}
|
||||
key="cancel"
|
||||
placement="top"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
|
||||
const threadTooltipButton = <ReplyInThreadButton mxEvent={this.props.mxEvent} key="reply_thread" />;
|
||||
|
||||
// We show a different toolbar for failed events, so detect that first.
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const editStatus = mxEvent.replacingEvent()?.status;
|
||||
const redactStatus = mxEvent.localRedactionEvent()?.status;
|
||||
const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus);
|
||||
const isFailed = [mxEvent.status, editStatus, redactStatus].includes(EventStatus.NOT_SENT);
|
||||
if (allowCancel && isFailed) {
|
||||
// The resend button needs to appear ahead of the edit button, so insert to the
|
||||
// start of the opts
|
||||
toolbarOpts.splice(
|
||||
0,
|
||||
0,
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton mx_MessageActionBar_retryButton"
|
||||
title={_t("action|retry")}
|
||||
onClick={this.onResendClick}
|
||||
onContextMenu={this.onResendClick}
|
||||
key="resend"
|
||||
placement="top"
|
||||
>
|
||||
<RestartIcon />
|
||||
</RovingAccessibleButton>,
|
||||
);
|
||||
|
||||
// The delete button should appear last, so we can just drop it at the end
|
||||
toolbarOpts.push(cancelSendingButton);
|
||||
} else {
|
||||
if (isContentActionable(this.props.mxEvent)) {
|
||||
// Like the resend button, the react and reply buttons need to appear before the edit.
|
||||
// The only catch is we do the reply button first so that we can make sure the react
|
||||
// button is the very first button without having to do length checks for `splice()`.
|
||||
|
||||
if (this.context.canSendMessages) {
|
||||
if (this.showReplyInThreadAction) {
|
||||
toolbarOpts.splice(0, 0, threadTooltipButton);
|
||||
}
|
||||
toolbarOpts.splice(
|
||||
0,
|
||||
0,
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
title={_t("action|reply")}
|
||||
onClick={this.onReplyClick}
|
||||
onContextMenu={this.onReplyClick}
|
||||
key="reply"
|
||||
placement="top"
|
||||
>
|
||||
<ReplyIcon />
|
||||
</RovingAccessibleButton>,
|
||||
);
|
||||
}
|
||||
// We hide the react button in search results as we don't show reactions in results
|
||||
if (this.context.canReact && !this.context.search) {
|
||||
toolbarOpts.splice(
|
||||
0,
|
||||
0,
|
||||
<ReactButton
|
||||
mxEvent={this.props.mxEvent}
|
||||
reactions={this.props.reactions}
|
||||
onFocusChange={this.onFocusChange}
|
||||
key="react"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// XXX: Assuming that the underlying tile will be a media event if it is eligible media.
|
||||
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
|
||||
toolbarOpts.splice(
|
||||
0,
|
||||
0,
|
||||
<DownloadActionButton
|
||||
mxEvent={this.props.mxEvent}
|
||||
mediaEventHelperGet={() => this.props.getTile()?.getMediaHelper?.()}
|
||||
key="download"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (MediaEventHelper.canHide(this.props.mxEvent)) {
|
||||
toolbarOpts.splice(0, 0, <HideActionButton mxEvent={this.props.mxEvent} key="hide" />);
|
||||
}
|
||||
} else if (
|
||||
// Show thread icon even for deleted messages, but only within main timeline
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Room &&
|
||||
this.props.mxEvent.getThread()
|
||||
) {
|
||||
toolbarOpts.unshift(threadTooltipButton);
|
||||
}
|
||||
|
||||
if (allowCancel) {
|
||||
toolbarOpts.push(cancelSendingButton);
|
||||
}
|
||||
|
||||
if (this.props.isQuoteExpanded !== undefined && shouldDisplayReply(this.props.mxEvent)) {
|
||||
const expandClassName = classNames({
|
||||
mx_MessageActionBar_iconButton: true,
|
||||
mx_MessageActionBar_expandCollapseMessageButton: true,
|
||||
});
|
||||
|
||||
toolbarOpts.push(
|
||||
<RovingAccessibleButton
|
||||
className={expandClassName}
|
||||
title={
|
||||
this.props.isQuoteExpanded
|
||||
? _t("timeline|mab|collapse_reply_chain")
|
||||
: _t("timeline|mab|expand_reply_chain")
|
||||
}
|
||||
caption={_t(ALTERNATE_KEY_NAME[Key.SHIFT]) + " + " + _t("action|click")}
|
||||
onClick={this.props.toggleThreadExpanded}
|
||||
key="expand"
|
||||
placement="top"
|
||||
>
|
||||
{this.props.isQuoteExpanded ? <CollapseIcon /> : <ExpandIcon />}
|
||||
</RovingAccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
// The menu button should be last, so dump it there.
|
||||
toolbarOpts.push(
|
||||
<OptionsButton
|
||||
mxEvent={this.props.mxEvent}
|
||||
getReplyChain={this.props.getReplyChain}
|
||||
getTile={this.props.getTile}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
onFocusChange={this.onFocusChange}
|
||||
key="menu"
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
|
||||
return (
|
||||
<Toolbar className="mx_MessageActionBar" aria-label={_t("timeline|mab|label")} aria-live="off">
|
||||
{toolbarOpts}
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import React, {
|
||||
useState,
|
||||
type JSX,
|
||||
type Ref,
|
||||
type FocusEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -50,6 +51,7 @@ import { uniqueId, uniqBy } from "lodash";
|
||||
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
useCreateAutoDisposedViewModel,
|
||||
ActionBarView,
|
||||
MessageTimestampView,
|
||||
PinnedMessageBadge,
|
||||
ReactionsRowButtonView,
|
||||
@@ -77,13 +79,11 @@ import PlatformPeg from "../../../PlatformPeg";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import SenderProfile from "../messages/SenderProfile";
|
||||
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
|
||||
import MessageActionBar from "../messages/MessageActionBar";
|
||||
import ReactionPicker from "../emojipicker/ReactionPicker";
|
||||
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
|
||||
import { isContentActionable } from "../../../utils/EventUtils";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { copyPlaintext } from "../../../utils/strings";
|
||||
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
@@ -96,7 +96,6 @@ import { ReadReceiptGroup } from "./ReadReceiptGroup";
|
||||
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
||||
import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar";
|
||||
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
@@ -105,6 +104,7 @@ import { ElementCallEventType } from "../../../call-types";
|
||||
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
|
||||
import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { CardContext } from "../right_panel/context";
|
||||
import {
|
||||
MessageTimestampViewModel,
|
||||
type MessageTimestampViewModelProps,
|
||||
@@ -114,6 +114,8 @@ import {
|
||||
MAX_ITEMS_WHEN_LIMITED,
|
||||
ReactionsRowViewModel,
|
||||
} from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel";
|
||||
import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel";
|
||||
import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
||||
|
||||
@@ -268,6 +270,7 @@ export interface EventTileProps {
|
||||
interface IState {
|
||||
// Whether the action bar is focused.
|
||||
actionBarFocused: boolean;
|
||||
showActionBarFromFocus: boolean;
|
||||
|
||||
/**
|
||||
* E2EE shield we should show for decryption problems.
|
||||
@@ -342,6 +345,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
this.state = {
|
||||
// Whether the action bar is focused.
|
||||
actionBarFocused: false,
|
||||
showActionBarFromFocus: false,
|
||||
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
@@ -453,7 +457,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
this.verifyEvent();
|
||||
}
|
||||
|
||||
private updateThread = (thread: Thread): void => {
|
||||
private readonly updateThread = (thread: Thread): void => {
|
||||
this.setState({ thread });
|
||||
};
|
||||
|
||||
@@ -498,7 +502,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.observe(this.ref.current);
|
||||
}
|
||||
|
||||
private onNewThread = (thread: Thread): void => {
|
||||
private readonly onNewThread = (thread: Thread): void => {
|
||||
if (thread.id === this.props.mxEvent.getId()) {
|
||||
this.updateThread(thread);
|
||||
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
|
||||
@@ -561,9 +565,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
}
|
||||
}
|
||||
|
||||
private viewInRoom = (evt: ButtonEvent): void => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
private readonly onViewInRoomClick = (_anchor: HTMLElement | null): void => {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
@@ -573,16 +575,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
});
|
||||
};
|
||||
|
||||
private copyLinkToThread = async (evt: ButtonEvent): Promise<void> => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
private readonly onCopyLinkToThreadClick = async (_anchor: HTMLElement | null): Promise<void> => {
|
||||
const { permalinkCreator, mxEvent } = this.props;
|
||||
if (!permalinkCreator) return;
|
||||
const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()!);
|
||||
await copyPlaintext(matrixToUrl);
|
||||
};
|
||||
|
||||
private onRoomReceipt = (ev: MatrixEvent, room: Room): void => {
|
||||
private readonly onRoomReceipt = (ev: MatrixEvent, room: Room): void => {
|
||||
// ignore events for other rooms
|
||||
const tileRoom = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
|
||||
if (room !== tileRoom) return;
|
||||
@@ -604,20 +604,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
|
||||
/** called when the event is decrypted after we show it.
|
||||
*/
|
||||
private onDecrypted = (): void => {
|
||||
private readonly onDecrypted = (): void => {
|
||||
// we need to re-verify the sending device.
|
||||
this.verifyEvent();
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => {
|
||||
private readonly onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => {
|
||||
if (userId === this.props.mxEvent.getSender()) {
|
||||
this.verifyEvent();
|
||||
}
|
||||
};
|
||||
|
||||
/** called when the event is edited after we show it. */
|
||||
private onReplaced = (): void => {
|
||||
private readonly onReplaced = (): void => {
|
||||
// re-verify the event if it is replaced (the edit may not be verified)
|
||||
this.verifyEvent();
|
||||
};
|
||||
@@ -732,7 +732,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight);
|
||||
}
|
||||
|
||||
private onSenderProfileClick = (): void => {
|
||||
private readonly onSenderProfileClick = (): void => {
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
action: Action.ComposerInsert,
|
||||
userId: this.props.mxEvent.getSender()!,
|
||||
@@ -740,7 +740,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
});
|
||||
};
|
||||
|
||||
private onPermalinkClicked = (e: MouseEvent): void => {
|
||||
private readonly onPermalinkClicked = (e: MouseEvent): void => {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Element when clicked.
|
||||
e.preventDefault();
|
||||
@@ -855,15 +855,34 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
return null;
|
||||
}
|
||||
|
||||
private onActionBarFocusChange = (actionBarFocused: boolean): void => {
|
||||
this.setState({ actionBarFocused });
|
||||
private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => {
|
||||
this.setState((prevState) => ({
|
||||
actionBarFocused,
|
||||
hover: actionBarFocused ? prevState.hover : (this.ref.current?.matches(":hover") ?? false),
|
||||
}));
|
||||
};
|
||||
|
||||
private getTile: () => IEventTileType | null = () => this.tile.current;
|
||||
private readonly onFocusWithin = (event: FocusEvent<HTMLElement>): void => {
|
||||
// Show the action toolbar for keyboard-visible focus, with what-input as a fallback signal.
|
||||
const target = event.target as HTMLElement;
|
||||
const showActionBarFromFocus =
|
||||
target.matches(":focus-visible") || document.body.dataset["data-whatinput"] === "keyboard";
|
||||
this.setState({ focusWithin: true, showActionBarFromFocus });
|
||||
};
|
||||
|
||||
private getReplyChain = (): ReplyChain | null => this.replyChain.current;
|
||||
private readonly onBlurWithin = (event: FocusEvent<HTMLElement>): void => {
|
||||
if (event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
private getReactions = (): Relations | null => {
|
||||
this.setState({ focusWithin: false, showActionBarFromFocus: false });
|
||||
};
|
||||
|
||||
private readonly getTile: () => IEventTileType | null = () => this.tile.current;
|
||||
|
||||
private readonly getReplyChain = (): ReplyChain | null => this.replyChain.current;
|
||||
|
||||
private readonly getReactions = (): Relations | null => {
|
||||
if (!this.props.showReactions || !this.props.getRelationsForEvent) {
|
||||
return null;
|
||||
}
|
||||
@@ -871,7 +890,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction") ?? null;
|
||||
};
|
||||
|
||||
private onReactionsCreated = (relationType: string, eventType: string): void => {
|
||||
private readonly onReactionsCreated = (relationType: string, eventType: string): void => {
|
||||
if (relationType !== "m.annotation" || eventType !== "m.reaction") {
|
||||
return;
|
||||
}
|
||||
@@ -880,11 +899,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
});
|
||||
};
|
||||
|
||||
private onContextMenu = (ev: React.MouseEvent): void => {
|
||||
private readonly onContextMenu = (ev: React.MouseEvent): void => {
|
||||
this.showContextMenu(ev);
|
||||
};
|
||||
|
||||
private onTimestampContextMenu = (ev: React.MouseEvent): void => {
|
||||
private readonly onTimestampContextMenu = (ev: React.MouseEvent): void => {
|
||||
this.showContextMenu(ev, this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId()!));
|
||||
};
|
||||
|
||||
@@ -917,17 +936,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
link: anchorElement?.href || permalink,
|
||||
},
|
||||
actionBarFocused: true,
|
||||
hover: false,
|
||||
});
|
||||
}
|
||||
|
||||
private onCloseMenu = (): void => {
|
||||
private readonly onCloseMenu = (): void => {
|
||||
this.setState({
|
||||
contextMenu: undefined,
|
||||
actionBarFocused: false,
|
||||
hover: false,
|
||||
});
|
||||
};
|
||||
|
||||
private setQuoteExpanded = (expanded: boolean): void => {
|
||||
private readonly setQuoteExpanded = (expanded: boolean): void => {
|
||||
this.setState({
|
||||
isQuoteExpanded: expanded,
|
||||
});
|
||||
@@ -1150,9 +1171,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
}
|
||||
}
|
||||
|
||||
const showMessageActionBar = !isEditing && !this.props.forExport;
|
||||
const showMessageActionBar =
|
||||
!isEditing &&
|
||||
!this.props.forExport &&
|
||||
(this.state.hover ||
|
||||
this.state.showActionBarFromFocus ||
|
||||
(this.state.actionBarFocused && !this.state.contextMenu));
|
||||
const actionBar = showMessageActionBar ? (
|
||||
<MessageActionBar
|
||||
<ActionBarWrapper
|
||||
mxEvent={this.props.mxEvent}
|
||||
reactions={this.state.reactions}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
@@ -1286,8 +1312,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
"data-event-id": this.props.mxEvent.getId(),
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
"onFocus": () => this.setState({ focusWithin: true }),
|
||||
"onBlur": () => this.setState({ focusWithin: false }),
|
||||
"onFocus": this.onFocusWithin,
|
||||
"onBlur": this.onBlurWithin,
|
||||
},
|
||||
[
|
||||
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
|
||||
@@ -1348,15 +1374,15 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
"data-has-reply": !!replyChain,
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
"onFocus": () => this.setState({ focusWithin: true }),
|
||||
"onBlur": () => this.setState({ focusWithin: false }),
|
||||
"onFocus": this.onFocusWithin,
|
||||
"onBlur": this.onBlurWithin,
|
||||
"onClick": (ev: MouseEvent) => {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
let index = -1;
|
||||
if (target.parentElement) index = Array.from(target.parentElement.children).indexOf(target);
|
||||
switch (this.context.timelineRenderingType) {
|
||||
case TimelineRenderingType.Notification:
|
||||
this.viewInRoom(ev);
|
||||
this.onViewInRoomClick(null);
|
||||
break;
|
||||
case TimelineRenderingType.ThreadsList:
|
||||
dis.dispatch<ShowThreadPayload>({
|
||||
@@ -1411,9 +1437,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
{this.renderThreadPanelSummary()}
|
||||
</div>
|
||||
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
|
||||
<EventTileThreadToolbar
|
||||
viewInRoom={this.viewInRoom}
|
||||
copyLinkToThread={this.copyLinkToThread}
|
||||
<ThreadListActionBarWrapper
|
||||
onViewInRoomClick={this.onViewInRoomClick}
|
||||
onCopyLinkClick={this.onCopyLinkToThreadClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1481,8 +1507,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
"data-has-reply": !!replyChain,
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
"onFocus": () => this.setState({ focusWithin: true }),
|
||||
"onBlur": () => this.setState({ focusWithin: false }),
|
||||
"onFocus": this.onFocusWithin,
|
||||
"onBlur": this.onBlurWithin,
|
||||
},
|
||||
<>
|
||||
{ircTimestamp}
|
||||
@@ -1861,3 +1887,160 @@ function ReactionsRowWrapper({ mxEvent, reactions }: Readonly<ReactionsRowWrappe
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionBarWrapperProps {
|
||||
mxEvent: MatrixEvent;
|
||||
reactions?: Relations | null;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
getTile: () => IEventTileType | null;
|
||||
getReplyChain: () => ReplyChain | null;
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
isQuoteExpanded?: boolean;
|
||||
toggleThreadExpanded: () => void;
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
}
|
||||
|
||||
interface ThreadListActionBarWrapperProps {
|
||||
onViewInRoomClick: (anchor: HTMLElement | null) => void;
|
||||
onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function ThreadListActionBarWrapper({
|
||||
onViewInRoomClick,
|
||||
onCopyLinkClick,
|
||||
}: Readonly<ThreadListActionBarWrapperProps>): JSX.Element {
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new ThreadListActionBarViewModel({
|
||||
onViewInRoomClick,
|
||||
onCopyLinkClick,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
vm.setProps({
|
||||
onViewInRoomClick,
|
||||
onCopyLinkClick,
|
||||
});
|
||||
}, [vm, onViewInRoomClick, onCopyLinkClick]);
|
||||
|
||||
return <ActionBarView vm={vm} className="mx_ThreadActionBar" />;
|
||||
}
|
||||
|
||||
function ActionBarWrapper({
|
||||
mxEvent,
|
||||
reactions,
|
||||
permalinkCreator,
|
||||
getTile,
|
||||
getReplyChain,
|
||||
onFocusChange,
|
||||
isQuoteExpanded,
|
||||
toggleThreadExpanded,
|
||||
getRelationsForEvent,
|
||||
}: Readonly<ActionBarWrapperProps>): JSX.Element {
|
||||
const roomContext = useContext(RoomContext);
|
||||
const { isCard } = useContext(CardContext);
|
||||
const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState<DOMRect | null>(null);
|
||||
const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState<DOMRect | null>(null);
|
||||
const isSearch = Boolean(roomContext.search);
|
||||
const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => {
|
||||
setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null);
|
||||
}, []);
|
||||
const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => {
|
||||
setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null);
|
||||
}, []);
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new EventTileActionBarViewModel({
|
||||
mxEvent,
|
||||
timelineRenderingType: roomContext.timelineRenderingType,
|
||||
canSendMessages: roomContext.canSendMessages,
|
||||
canReact: roomContext.canReact,
|
||||
isSearch,
|
||||
isCard,
|
||||
isQuoteExpanded,
|
||||
onToggleThreadExpanded: toggleThreadExpanded,
|
||||
onOptionsClick: handleOptionsClick,
|
||||
onReactionsClick: handleReactionsClick,
|
||||
getRelationsForEvent,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
vm.setProps({
|
||||
mxEvent,
|
||||
timelineRenderingType: roomContext.timelineRenderingType,
|
||||
canSendMessages: roomContext.canSendMessages,
|
||||
canReact: roomContext.canReact,
|
||||
isSearch,
|
||||
isCard,
|
||||
isQuoteExpanded,
|
||||
getRelationsForEvent,
|
||||
onToggleThreadExpanded: toggleThreadExpanded,
|
||||
onOptionsClick: handleOptionsClick,
|
||||
onReactionsClick: handleReactionsClick,
|
||||
});
|
||||
}, [
|
||||
vm,
|
||||
mxEvent,
|
||||
roomContext.timelineRenderingType,
|
||||
roomContext.canSendMessages,
|
||||
roomContext.canReact,
|
||||
isSearch,
|
||||
isCard,
|
||||
isQuoteExpanded,
|
||||
getRelationsForEvent,
|
||||
handleOptionsClick,
|
||||
handleReactionsClick,
|
||||
toggleThreadExpanded,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect));
|
||||
}, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]);
|
||||
|
||||
useEffect(() => {
|
||||
setOptionsMenuAnchorRect(null);
|
||||
setReactionsMenuAnchorRect(null);
|
||||
}, [mxEvent]);
|
||||
|
||||
const closeOptionsMenu = useCallback((): void => {
|
||||
setOptionsMenuAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
const closeReactionsMenu = useCallback((): void => {
|
||||
setReactionsMenuAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
const tile = getTile();
|
||||
const replyChain = getReplyChain();
|
||||
const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined;
|
||||
const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionBarView vm={vm} className="mx_MessageActionBar" />
|
||||
{optionsMenuAnchorRect ? (
|
||||
<MessageContextMenu
|
||||
{...aboveLeftOf(optionsMenuAnchorRect)}
|
||||
mxEvent={mxEvent}
|
||||
permalinkCreator={permalinkCreator}
|
||||
eventTileOps={eventTileOps}
|
||||
collapseReplyChain={collapseReplyChain}
|
||||
onFinished={closeOptionsMenu}
|
||||
getRelationsForEvent={getRelationsForEvent}
|
||||
/>
|
||||
) : null}
|
||||
{reactionsMenuAnchorRect ? (
|
||||
<ContextMenu
|
||||
{...aboveLeftOf(reactionsMenuAnchorRect)}
|
||||
onFinished={closeReactionsMenu}
|
||||
managed={false}
|
||||
focusLock
|
||||
>
|
||||
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionsMenu} />
|
||||
</ContextMenu>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
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
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { LinkIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex";
|
||||
import Toolbar from "../../../../accessibility/Toolbar";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { type ButtonEvent } from "../../elements/AccessibleButton";
|
||||
|
||||
export function EventTileThreadToolbar({
|
||||
viewInRoom,
|
||||
copyLinkToThread,
|
||||
}: {
|
||||
viewInRoom: (evt: ButtonEvent) => void;
|
||||
copyLinkToThread: (evt: ButtonEvent) => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Toolbar className="mx_MessageActionBar" aria-label={_t("timeline|mab|label")} aria-live="off">
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
onClick={viewInRoom}
|
||||
title={_t("timeline|mab|view_in_room")}
|
||||
key="view_in_room"
|
||||
>
|
||||
<VisibilityOnIcon />
|
||||
</RovingAccessibleButton>
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
onClick={copyLinkToThread}
|
||||
title={_t("timeline|mab|copy_link_thread")}
|
||||
key="copy_link_to_thread"
|
||||
>
|
||||
<LinkIcon />
|
||||
</RovingAccessibleButton>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user