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:
@@ -56,6 +56,7 @@ module.exports = {
|
||||
{ from: "res/css/views/rooms/_EditMessageComposer.pcss", type: "css" },
|
||||
{ from: "res/css/views/right_panel/_BaseCard.pcss", type: "css" },
|
||||
{ from: "res/css/views/messages/_MessageActionBar.pcss", type: "css" },
|
||||
{ from: "res/css/views/messages/_ThreadActionBar.pcss", type: "css" },
|
||||
{ from: "res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss", type: "css" },
|
||||
{ from: "res/css/views/elements/_ToggleSwitch.pcss", type: "css" },
|
||||
{ from: "res/css/views/settings/tabs/_SettingsTab.pcss", type: "css" },
|
||||
|
||||
@@ -238,6 +238,7 @@
|
||||
@import "./views/messages/_ReactionsRow.pcss";
|
||||
@import "./views/messages/_RoomAvatarEvent.pcss";
|
||||
@import "./views/messages/_TextualEvent.pcss";
|
||||
@import "./views/messages/_ThreadActionBar.pcss";
|
||||
@import "./views/messages/_UnknownBody.pcss";
|
||||
@import "./views/messages/_ViewSourceEvent.pcss";
|
||||
@import "./views/messages/_common_CryptoEvent.pcss";
|
||||
|
||||
@@ -82,13 +82,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MessageActionBar .mx_AccessibleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.mx_HistoryActionBar {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
padding-inline-start: $spacing-8;
|
||||
padding-inline-end: $spacing-8;
|
||||
|
||||
font-size: $font-15px;
|
||||
.mx_HistoryActionBar [data-presentation="label"] {
|
||||
line-height: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
.mx_MessageActionBar {
|
||||
--MessageActionBar-size-button: 28px;
|
||||
--MessageActionBar-size-margin: 3px;
|
||||
--MessageActionBar-item-hover-background: var(--cpd-color-bg-subtle-secondary);
|
||||
--MessageActionBar-item-hover-borderRadius: 6px;
|
||||
--MessageActionBar-item-hover-zIndex: 1;
|
||||
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--cpd-space-0-5x);
|
||||
line-height: $font-24px;
|
||||
border-radius: 8px;
|
||||
background: $background;
|
||||
border: var(--cpd-border-width-1) solid var(--cpd-color-border-disabled);
|
||||
top: calc(
|
||||
-1 *
|
||||
(
|
||||
@@ -75,51 +64,4 @@ Please see LICENSE files in the repository root for full details.
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> * {
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: var(--MessageActionBar-size-margin);
|
||||
|
||||
&:hover {
|
||||
background: var(--MessageActionBar-item-hover-background);
|
||||
border-radius: var(--MessageActionBar-item-hover-borderRadius);
|
||||
z-index: var(--MessageActionBar-item-hover-zIndex);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MessageActionBar_iconButton {
|
||||
--MessageActionBar-icon-size: 20px;
|
||||
width: var(--MessageActionBar-size-button);
|
||||
height: var(--MessageActionBar-size-button);
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
height: var(--MessageActionBar-icon-size);
|
||||
width: var(--MessageActionBar-icon-size);
|
||||
flex: 0 0 var(--MessageActionBar-icon-size);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
&.mx_MessageActionBar_downloadButton {
|
||||
&.mx_MessageActionBar_downloadSpinnerButton {
|
||||
svg {
|
||||
display: none; /* hide the download icon */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_ThreadActionBar {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
top: calc(-1 * (28px + 2 * (3px + var(--cpd-border-width-1))));
|
||||
right: 8px;
|
||||
user-select: none;
|
||||
/* Ensure the action bar appears above other things like the read marker */
|
||||
/* and sender avatar (for small screens) */
|
||||
z-index: 10;
|
||||
|
||||
/* Adds a previous event safe area so that you can't accidentally hover the */
|
||||
/* previous event while trying to mouse into the action bar or from the */
|
||||
/* react button to its tooltip. */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
/* tooltip safe mousing area + tooltip overhang + */
|
||||
/* action bar + action bar offset from event */
|
||||
width: calc(10px + 48px + 100% + 8px);
|
||||
/* safe area + action bar */
|
||||
height: calc(20px + 100%);
|
||||
top: -12px;
|
||||
left: -58px;
|
||||
z-index: -1;
|
||||
cursor: initial;
|
||||
|
||||
/* stylelint-disable-next-line max-line-length */
|
||||
.mx_GenericEventListSummary[data-layout="bubble"]
|
||||
.mx_GenericEventListSummary_toggle
|
||||
~ .mx_GenericEventListSummary_unstyledList
|
||||
.mx_EventTile_info:first-of-type
|
||||
& {
|
||||
/* improve clickability of "collapse" link button on bubble layout by reducing width and height values */
|
||||
/* mx_GenericEventListSummary_toggle ~: to apply rules to action bar when "collapse" button is available */
|
||||
/* mx_EventTile_info:first-of-type: to apply rules to the info event tile just under "collapse" button */
|
||||
/* TODO: use a new class name instead */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.mx_EventTile_info .mx_ViewSourceEvent ~ & {
|
||||
/* improve clickability of view source event toggle button by removing vertical safe area */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -938,10 +938,10 @@ $left-gutter: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile:hover .mx_MessageActionBar,
|
||||
.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar,
|
||||
[data-whatinput="keyboard"] .mx_EventTile:focus-within .mx_MessageActionBar,
|
||||
.mx_EventTile:focus-visible:focus-within .mx_MessageActionBar {
|
||||
.mx_EventTile:hover .mx_ThreadActionBar,
|
||||
.mx_EventTile.mx_EventTile_actionBarFocused .mx_ThreadActionBar,
|
||||
[data-whatinput="keyboard"] .mx_EventTile:focus-within .mx_ThreadActionBar,
|
||||
.mx_EventTile:focus-visible:focus-within .mx_ThreadActionBar {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,64 +8,49 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { useCallback } from "react";
|
||||
import { JoinRule, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { SettingLevel } from "../settings/SettingLevel";
|
||||
import { useSettingValue } from "./useSettings";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
|
||||
import { MediaPreviewValue } from "../@types/media_preview";
|
||||
import { useRoomState } from "./useRoomState";
|
||||
|
||||
const PRIVATE_JOIN_RULES: JoinRule[] = [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted];
|
||||
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
|
||||
import { computeMediaVisibility, setMediaVisibility } from "../utils/media/mediaVisibility";
|
||||
|
||||
/**
|
||||
* Should the media event be visible in the client, or hidden.
|
||||
* Determine whether media for an event should be visible in the client and expose a setter for
|
||||
* a per-event override.
|
||||
*
|
||||
* This function uses the `mediaPreviewConfig` setting to determine the rules for the room
|
||||
* along with the `showMediaEventIds` setting for specific events.
|
||||
* Visibility is resolved from the effective `mediaPreviewConfig` setting together with any
|
||||
* event-specific overrides stored in `showMediaEventIds`.
|
||||
*
|
||||
* A function may be provided to alter the visible state.
|
||||
* @param mxEvent - The event that contains the media. If omitted, visibility is derived from the
|
||||
* current setting defaults and the returned setter is a no-op.
|
||||
*
|
||||
* @param The event that contains the media. If not provided, the global rule is used.
|
||||
*
|
||||
* @returns Returns a tuple of:
|
||||
* A boolean describing the hidden status.
|
||||
* A function to show or hide the event.
|
||||
* @returns A tuple containing the effective visibility for the event and a function that stores a
|
||||
* device-local visibility override for that event.
|
||||
*/
|
||||
export function useMediaVisible(mxEvent?: MatrixEvent): [boolean, (visible: boolean) => void] {
|
||||
const eventId = mxEvent?.getId();
|
||||
const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", mxEvent?.getRoomId());
|
||||
const client = useMatrixClientContext();
|
||||
const roomId = mxEvent?.getRoomId();
|
||||
const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", roomId);
|
||||
const eventVisibility = useSettingValue("showMediaEventIds");
|
||||
const room = client.getRoom(mxEvent?.getRoomId()) ?? undefined;
|
||||
const room = roomId ? (client.getRoom(roomId) ?? undefined) : undefined;
|
||||
const joinRule = useRoomState(room, (state) => state.getJoinRule());
|
||||
|
||||
const setMediaVisible = useCallback(
|
||||
(visible: boolean) => {
|
||||
SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
|
||||
...eventVisibility,
|
||||
[eventId!]: visible,
|
||||
});
|
||||
if (!mxEvent) return;
|
||||
void setMediaVisibility(mxEvent, visible);
|
||||
},
|
||||
[eventId, eventVisibility],
|
||||
[mxEvent],
|
||||
);
|
||||
|
||||
const roomIsPrivate = joinRule ? PRIVATE_JOIN_RULES.includes(joinRule) : false;
|
||||
|
||||
const explicitEventVisiblity = eventId ? eventVisibility[eventId] : undefined;
|
||||
// Always prefer the explicit per-event user preference here.
|
||||
if (explicitEventVisiblity !== undefined) {
|
||||
return [explicitEventVisiblity, setMediaVisible];
|
||||
} else if (mxEvent?.getSender() === client.getUserId()) {
|
||||
// If this event is ours and we've not set an explicit visibility, default to on.
|
||||
return [true, setMediaVisible];
|
||||
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Off) {
|
||||
return [false, setMediaVisible];
|
||||
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.On) {
|
||||
return [true, setMediaVisible];
|
||||
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Private) {
|
||||
return [roomIsPrivate, setMediaVisible];
|
||||
} else {
|
||||
// Invalid setting.
|
||||
console.warn("Invalid media visibility setting", mediaPreviewSetting.media_previews);
|
||||
return [false, setMediaVisible];
|
||||
}
|
||||
return [
|
||||
computeMediaVisibility(
|
||||
mediaPreviewSetting,
|
||||
eventVisibility,
|
||||
client.getUserId() ?? undefined,
|
||||
mxEvent?.getId(),
|
||||
mxEvent?.getSender(),
|
||||
joinRule ? [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted].includes(joinRule) : false,
|
||||
),
|
||||
setMediaVisible,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"cancel": "Cancel",
|
||||
"change": "Change",
|
||||
"clear": "Clear",
|
||||
"click": "Click",
|
||||
"click_to_copy": "Click to copy",
|
||||
"close": "Close",
|
||||
"collapse": "Collapse",
|
||||
@@ -66,7 +65,6 @@
|
||||
"go": "Go",
|
||||
"go_back": "Go back",
|
||||
"got_it": "Got it",
|
||||
"hide": "Hide",
|
||||
"hide_advanced": "Hide advanced",
|
||||
"hold": "Hold",
|
||||
"ignore": "Ignore",
|
||||
@@ -3316,7 +3314,6 @@
|
||||
},
|
||||
"empty_description": "Use “%(replyInThread)s” when hovering over a message.",
|
||||
"empty_title": "Threads help keep your conversations on-topic and easy to track.",
|
||||
"error_start_thread_existing_relation": "Can't create a thread from an event with an existing relation",
|
||||
"mark_all_read": "Mark all as read",
|
||||
"my_threads": "My threads",
|
||||
"my_threads_description": "Shows all threads you've participated in",
|
||||
@@ -3360,7 +3357,6 @@
|
||||
"unable_to_decrypt": "Unable to decrypt message"
|
||||
},
|
||||
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
|
||||
"download_action_decrypting": "Decrypting",
|
||||
"download_action_downloading": "Downloading",
|
||||
"download_failed": "Download failed",
|
||||
"download_failed_description": "An error occurred while downloading this file",
|
||||
@@ -3560,10 +3556,7 @@
|
||||
"removed": "%(widgetName)s widget removed by %(senderName)s"
|
||||
},
|
||||
"mab": {
|
||||
"collapse_reply_chain": "Collapse quotes",
|
||||
"copy_link_thread": "Copy link to thread",
|
||||
"expand_reply_chain": "Expand quotes",
|
||||
"label": "Message Actions",
|
||||
"view_in_room": "View in room"
|
||||
},
|
||||
"mjolnir": {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
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 { JoinRule, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { type MediaPreviewConfig, MediaPreviewValue } from "../../@types/media_preview";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
/**
|
||||
* Determine whether a room should be treated as private when applying media preview defaults.
|
||||
*
|
||||
* @param client - Matrix client used to resolve the room and its current join rule.
|
||||
* @param roomId - Room to inspect. If omitted or unknown, the room is treated as non-private.
|
||||
* @returns `true` when the room's join rule restricts membership, otherwise `false`.
|
||||
*/
|
||||
function isRoomPrivate(client: MatrixClient, roomId?: string): boolean {
|
||||
const room = roomId ? client.getRoom(roomId) : undefined;
|
||||
const joinRule = room?.currentState.getJoinRule();
|
||||
|
||||
switch (joinRule) {
|
||||
case JoinRule.Invite:
|
||||
case JoinRule.Knock:
|
||||
case JoinRule.Restricted:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether media for a single event should be shown.
|
||||
*
|
||||
* Precedence is:
|
||||
* 1. An explicit per-event override stored in `showMediaEventIds`
|
||||
* 2. Always show media in events sent by the current user
|
||||
* 3. Fall back to the room-level `mediaPreviewConfig` policy
|
||||
*
|
||||
* @param mediaPreviewSetting - Effective room-level media preview configuration.
|
||||
* @param eventVisibility - Per-event visibility overrides keyed by event ID.
|
||||
* @param userId - Current user ID, used to always show media sent by the local user.
|
||||
* @param eventId - Event being evaluated. Used to look up any explicit override.
|
||||
* @param sender - Sender of the event being evaluated.
|
||||
* @param roomIsPrivate - Whether the event's room should use the private-room preview behavior.
|
||||
* @returns `true` when media should be displayed for the event, otherwise `false`.
|
||||
*/
|
||||
export function computeMediaVisibility(
|
||||
mediaPreviewSetting: MediaPreviewConfig,
|
||||
eventVisibility: Record<string, boolean>,
|
||||
userId: string | undefined,
|
||||
eventId: string | undefined,
|
||||
sender: string | undefined,
|
||||
roomIsPrivate: boolean,
|
||||
): boolean {
|
||||
const explicitEventVisibility = eventId ? eventVisibility[eventId] : undefined;
|
||||
|
||||
if (explicitEventVisibility !== undefined) {
|
||||
return explicitEventVisibility;
|
||||
}
|
||||
|
||||
if (sender === userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (mediaPreviewSetting.media_previews) {
|
||||
case MediaPreviewValue.Off:
|
||||
return false;
|
||||
case MediaPreviewValue.On:
|
||||
return true;
|
||||
case MediaPreviewValue.Private:
|
||||
return roomIsPrivate;
|
||||
default:
|
||||
console.warn("Invalid media visibility setting", mediaPreviewSetting.media_previews);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective media visibility for a Matrix event using the current settings state.
|
||||
*
|
||||
* @param mxEvent - Event whose media visibility should be evaluated.
|
||||
* @param client - Matrix client used to resolve the current user and room metadata.
|
||||
* @returns `true` when media should be shown for the event, otherwise `false`.
|
||||
*/
|
||||
export function getMediaVisibility(mxEvent: MatrixEvent, client: MatrixClient): boolean {
|
||||
const eventId = mxEvent.getId();
|
||||
const roomId = mxEvent.getRoomId();
|
||||
const mediaPreviewSetting = SettingsStore.getValue("mediaPreviewConfig", roomId);
|
||||
const eventVisibility = SettingsStore.getValue("showMediaEventIds");
|
||||
|
||||
return computeMediaVisibility(
|
||||
mediaPreviewSetting,
|
||||
eventVisibility,
|
||||
client.getUserId() ?? undefined,
|
||||
eventId,
|
||||
mxEvent.getSender(),
|
||||
isRoomPrivate(client, roomId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a per-event override for whether media should be displayed on this device.
|
||||
*
|
||||
* @param mxEvent - Event whose media visibility override should be updated.
|
||||
* @param visible - Whether media for the event should be shown.
|
||||
* @returns A promise that resolves once the device-scoped setting has been updated.
|
||||
*/
|
||||
export async function setMediaVisibility(mxEvent: MatrixEvent, visible: boolean): Promise<void> {
|
||||
const eventId = mxEvent.getId();
|
||||
if (!eventId) return;
|
||||
|
||||
const eventVisibility = SettingsStore.getValue("showMediaEventIds");
|
||||
|
||||
await SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
|
||||
...eventVisibility,
|
||||
[eventId]: visible,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* 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 {
|
||||
ActionBarAction,
|
||||
BaseViewModel,
|
||||
type ActionBarViewActions,
|
||||
type ActionBarViewSnapshot,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
/** Props for the edit-history action bar view model. */
|
||||
export interface EditHistoryActionBarViewModelProps {
|
||||
/** Whether to include the remove action. */
|
||||
canRemove: boolean;
|
||||
/** Whether to include the view source action. */
|
||||
showViewSource: boolean;
|
||||
/** Called when the remove action is activated. */
|
||||
onRemoveClick?: (anchor: HTMLElement | null) => void;
|
||||
/** Called when the view source action is activated. */
|
||||
onViewSourceClick?: (anchor: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
/** View model for the label-style action bar shown in the edit-history panel. */
|
||||
export class EditHistoryActionBarViewModel
|
||||
extends BaseViewModel<ActionBarViewSnapshot, EditHistoryActionBarViewModelProps>
|
||||
implements ActionBarViewActions
|
||||
{
|
||||
public constructor(props: EditHistoryActionBarViewModelProps) {
|
||||
super(props, EditHistoryActionBarViewModel.buildSnapshot(props));
|
||||
}
|
||||
|
||||
private static buildSnapshot(props: EditHistoryActionBarViewModelProps): ActionBarViewSnapshot {
|
||||
const actions: ActionBarAction[] = [];
|
||||
|
||||
if (props.canRemove) {
|
||||
actions.push(ActionBarAction.Remove);
|
||||
}
|
||||
if (props.showViewSource) {
|
||||
actions.push(ActionBarAction.ViewSource);
|
||||
}
|
||||
|
||||
return {
|
||||
actions,
|
||||
presentation: "label",
|
||||
isDownloadEncrypted: false,
|
||||
isDownloadLoading: false,
|
||||
isPinned: false,
|
||||
isQuoteExpanded: false,
|
||||
isThreadReplyAllowed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Updates props and rebuilds the derived action-bar snapshot. */
|
||||
public setProps(newProps: Partial<EditHistoryActionBarViewModelProps>): void {
|
||||
this.props = {
|
||||
...this.props,
|
||||
...newProps,
|
||||
};
|
||||
this.snapshot.merge(EditHistoryActionBarViewModel.buildSnapshot(this.props));
|
||||
}
|
||||
|
||||
/** Forwards the remove action using the triggering button as the anchor. */
|
||||
public onRemoveClick = (anchor: HTMLElement | null): void => {
|
||||
this.props.onRemoveClick?.(anchor);
|
||||
};
|
||||
|
||||
/** Forwards the view source action using the triggering button as the anchor. */
|
||||
public onViewSourceClick = (anchor: HTMLElement | null): void => {
|
||||
this.props.onViewSourceClick?.(anchor);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* 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 {
|
||||
EventStatus,
|
||||
EventTimeline,
|
||||
EventType,
|
||||
MatrixEventEvent,
|
||||
M_BEACON_INFO,
|
||||
MsgType,
|
||||
RelationType,
|
||||
RoomStateEvent,
|
||||
type MatrixEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
ActionBarAction,
|
||||
BaseViewModel,
|
||||
type ActionBarViewActions,
|
||||
type ActionBarViewSnapshot,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
|
||||
import { type GetRelationsForEvent } from "../../components/views/rooms/EventTile";
|
||||
import { canCancel, canEditContent, editEvent, isContentActionable } from "../../utils/EventUtils";
|
||||
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import Resend from "../../Resend";
|
||||
import PinningUtils from "../../utils/PinningUtils";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { shouldDisplayReply } from "../../utils/Reply";
|
||||
import { MediaEventHelper } from "../../utils/MediaEventHelper";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { type SettingKey } from "../../settings/Settings";
|
||||
import { getMediaVisibility, setMediaVisibility } from "../../utils/media/mediaVisibility";
|
||||
import { FileDownloader } from "../../utils/FileDownloader";
|
||||
import { _t } from "../../languageHandler";
|
||||
import Modal from "../../Modal";
|
||||
import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
|
||||
import { ModuleApi } from "../../modules/Api";
|
||||
|
||||
/** Props for the event-tile action bar view model. */
|
||||
export interface EventTileActionBarViewModelProps {
|
||||
/** The event whose available actions are being resolved. */
|
||||
mxEvent: MatrixEvent;
|
||||
/** The timeline context the event is rendered within. */
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
/** Whether the current user can send message-based actions such as reply. */
|
||||
canSendMessages: boolean;
|
||||
/** Whether the current user can react to the event. */
|
||||
canReact: boolean;
|
||||
/** Whether the tile is being rendered in search results. */
|
||||
isSearch?: boolean;
|
||||
/** Whether the tile is being rendered inside a card-style surface. */
|
||||
isCard?: boolean;
|
||||
/** Whether the quoted reply chain is currently expanded. */
|
||||
isQuoteExpanded?: boolean;
|
||||
/** Called when the overflow options action is activated. */
|
||||
onOptionsClick?: (anchor: HTMLElement | null) => void;
|
||||
/** Called when the reactions action is activated. */
|
||||
onReactionsClick?: (anchor: HTMLElement | null) => void;
|
||||
/** Provides relations needed for editing when available. */
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
/** Called when the expand or collapse thread action is activated. */
|
||||
onToggleThreadExpanded?: (anchor: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface LocalActionBarState {
|
||||
canDownload: boolean;
|
||||
isDownloadLoading: boolean;
|
||||
}
|
||||
|
||||
interface DerivedEventState {
|
||||
showCancel: boolean;
|
||||
showEdit: boolean;
|
||||
showPinOrUnpin: boolean;
|
||||
showReact: boolean;
|
||||
showReply: boolean;
|
||||
showExpandCollapse: boolean;
|
||||
showReplyInThread: boolean;
|
||||
showThreadForDeletedMessage: boolean;
|
||||
isFailed: boolean;
|
||||
isPinned: boolean;
|
||||
isQuoteExpanded: boolean;
|
||||
isThreadReplyAllowed: boolean;
|
||||
}
|
||||
|
||||
interface DerivedMediaState {
|
||||
showHide: boolean;
|
||||
showDownload: boolean;
|
||||
isDownloadEncrypted: boolean;
|
||||
isDownloadLoading: boolean;
|
||||
}
|
||||
|
||||
/** View model for the timeline event action bar shown on event tiles. */
|
||||
export class EventTileActionBarViewModel
|
||||
extends BaseViewModel<ActionBarViewSnapshot, EventTileActionBarViewModelProps>
|
||||
implements ActionBarViewActions
|
||||
{
|
||||
private listenerCleanups: Array<() => void> = [];
|
||||
private downloadPermissionRequestId = 0;
|
||||
private downloadRequestId = 0;
|
||||
private canDownload = true;
|
||||
private isDownloadLoading = false;
|
||||
private readonly downloader = new FileDownloader();
|
||||
private downloadedBlob?: Blob;
|
||||
|
||||
public constructor(props: EventTileActionBarViewModelProps) {
|
||||
super(
|
||||
props,
|
||||
EventTileActionBarViewModel.buildSnapshot(props, {
|
||||
canDownload: true,
|
||||
isDownloadLoading: false,
|
||||
}),
|
||||
);
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
private static buildSnapshot(
|
||||
props: EventTileActionBarViewModelProps,
|
||||
localState: LocalActionBarState,
|
||||
): ActionBarViewSnapshot {
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
const eventState = EventTileActionBarViewModel.getDerivedEventState(props, client);
|
||||
const mediaState = EventTileActionBarViewModel.getDerivedMediaState(props.mxEvent, client, localState);
|
||||
|
||||
return {
|
||||
actions: EventTileActionBarViewModel.resolveActions(eventState, mediaState),
|
||||
presentation: "icon",
|
||||
isDownloadEncrypted: mediaState.isDownloadEncrypted,
|
||||
isDownloadLoading: mediaState.isDownloadLoading,
|
||||
isPinned: eventState.isPinned,
|
||||
isQuoteExpanded: eventState.isQuoteExpanded,
|
||||
isThreadReplyAllowed: eventState.isThreadReplyAllowed,
|
||||
};
|
||||
}
|
||||
|
||||
private static resolveActions(eventState: DerivedEventState, mediaState: DerivedMediaState): ActionBarAction[] {
|
||||
const actions: ActionBarAction[] = [];
|
||||
|
||||
if (eventState.showCancel && eventState.isFailed) {
|
||||
return [ActionBarAction.Resend, ActionBarAction.Cancel];
|
||||
}
|
||||
|
||||
if (mediaState.showHide) {
|
||||
actions.push(ActionBarAction.Hide);
|
||||
}
|
||||
if (mediaState.showDownload) {
|
||||
actions.push(ActionBarAction.Download);
|
||||
}
|
||||
if (eventState.showReact) {
|
||||
actions.push(ActionBarAction.React);
|
||||
}
|
||||
if (!eventState.showReply && eventState.showThreadForDeletedMessage) {
|
||||
actions.push(ActionBarAction.ReplyInThread);
|
||||
}
|
||||
if (eventState.showReply) {
|
||||
actions.push(ActionBarAction.Reply);
|
||||
}
|
||||
if (eventState.showReply && eventState.showReplyInThread) {
|
||||
actions.push(ActionBarAction.ReplyInThread);
|
||||
}
|
||||
if (eventState.showEdit) {
|
||||
actions.push(ActionBarAction.Edit);
|
||||
}
|
||||
if (eventState.showPinOrUnpin) {
|
||||
actions.push(ActionBarAction.Pin);
|
||||
}
|
||||
if (eventState.showCancel) {
|
||||
actions.push(ActionBarAction.Cancel);
|
||||
}
|
||||
if (eventState.showExpandCollapse) {
|
||||
actions.push(ActionBarAction.Expand);
|
||||
}
|
||||
|
||||
actions.push(ActionBarAction.Options);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static getDerivedEventState(
|
||||
props: EventTileActionBarViewModelProps,
|
||||
client: ReturnType<typeof MatrixClientPeg.safeGet>,
|
||||
): DerivedEventState {
|
||||
const { mxEvent } = props;
|
||||
const contentActionable = isContentActionable(mxEvent);
|
||||
const editStatus = mxEvent.replacingEvent()?.status;
|
||||
const redactStatus = mxEvent.localRedactionEvent()?.status;
|
||||
const relationType = mxEvent.getRelation()?.rel_type;
|
||||
|
||||
return {
|
||||
showCancel: canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus),
|
||||
showEdit: canEditContent(client, mxEvent),
|
||||
showPinOrUnpin: PinningUtils.canPin(client, mxEvent) || PinningUtils.canUnpin(client, mxEvent),
|
||||
showReact: contentActionable && props.canReact && !props.isSearch,
|
||||
showReply: contentActionable && props.canSendMessages,
|
||||
isThreadReplyAllowed: !(!!relationType && relationType !== RelationType.Thread),
|
||||
showExpandCollapse: props.isQuoteExpanded !== undefined && shouldDisplayReply(mxEvent),
|
||||
showReplyInThread: contentActionable && EventTileActionBarViewModel.canShowReplyInThreadAction(props),
|
||||
showThreadForDeletedMessage:
|
||||
!contentActionable &&
|
||||
props.timelineRenderingType === TimelineRenderingType.Room &&
|
||||
Boolean(mxEvent.getThread()),
|
||||
isFailed: [mxEvent.status, editStatus, redactStatus].includes(EventStatus.NOT_SENT),
|
||||
isPinned: PinningUtils.isPinned(client, mxEvent),
|
||||
isQuoteExpanded: props.isQuoteExpanded ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
private static getDerivedMediaState(
|
||||
mxEvent: MatrixEvent,
|
||||
client: ReturnType<typeof MatrixClientPeg.safeGet>,
|
||||
localState: LocalActionBarState,
|
||||
): DerivedMediaState {
|
||||
const contentActionable = isContentActionable(mxEvent);
|
||||
const mediaHelper = MediaEventHelper.isEligible(mxEvent) ? new MediaEventHelper(mxEvent) : undefined;
|
||||
|
||||
return {
|
||||
showDownload: contentActionable && Boolean(mediaHelper) && localState.canDownload,
|
||||
showHide: contentActionable && MediaEventHelper.canHide(mxEvent) && getMediaVisibility(mxEvent, client),
|
||||
isDownloadEncrypted: mediaHelper?.media.isEncrypted ?? false,
|
||||
isDownloadLoading: localState.isDownloadLoading,
|
||||
};
|
||||
}
|
||||
|
||||
private computeSnapshot(): ActionBarViewSnapshot {
|
||||
return EventTileActionBarViewModel.buildSnapshot(this.props, {
|
||||
canDownload: this.canDownload,
|
||||
isDownloadLoading: this.isDownloadLoading,
|
||||
});
|
||||
}
|
||||
|
||||
private static canShowReplyInThreadAction(props: EventTileActionBarViewModelProps): boolean {
|
||||
const inNotThreadTimeline = props.timelineRenderingType !== TimelineRenderingType.Thread;
|
||||
const content = props.mxEvent.getContent();
|
||||
const isAllowedMessageType =
|
||||
![MsgType.KeyVerificationRequest].includes(content.msgtype as MsgType) &&
|
||||
!M_BEACON_INFO.matches(props.mxEvent.getType());
|
||||
|
||||
return inNotThreadTimeline && isAllowedMessageType;
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
this.teardownListeners();
|
||||
|
||||
const { mxEvent } = this.props;
|
||||
const roomId = mxEvent.getRoomId();
|
||||
this.trackEvent(mxEvent, MatrixEventEvent.Status, this.refreshSnapshot);
|
||||
this.trackEvent(mxEvent, MatrixEventEvent.Decrypted, this.refreshSnapshot);
|
||||
this.trackEvent(mxEvent, MatrixEventEvent.BeforeRedaction, this.refreshSnapshot);
|
||||
this.watchSetting("mediaPreviewConfig", roomId ?? null);
|
||||
this.watchSetting("showMediaEventIds", null);
|
||||
|
||||
const roomState = roomId
|
||||
? MatrixClientPeg.safeGet().getRoom(roomId)?.getLiveTimeline().getState(EventTimeline.FORWARDS)
|
||||
: undefined;
|
||||
if (roomState) {
|
||||
roomState.on(RoomStateEvent.Events, this.onRoomEvent);
|
||||
this.addListenerCleanup(() => roomState.off(RoomStateEvent.Events, this.onRoomEvent));
|
||||
}
|
||||
|
||||
MatrixClientPeg.safeGet().decryptEventIfNeeded(mxEvent);
|
||||
void this.updateDownloadPermission(++this.downloadPermissionRequestId);
|
||||
}
|
||||
|
||||
private teardownListeners(): void {
|
||||
for (const cleanup of this.listenerCleanups) {
|
||||
cleanup();
|
||||
}
|
||||
this.listenerCleanups = [];
|
||||
}
|
||||
|
||||
private addListenerCleanup(cleanup: () => void): void {
|
||||
this.listenerCleanups.push(cleanup);
|
||||
}
|
||||
|
||||
private trackEvent(event: MatrixEvent, eventName: MatrixEventEvent, callback: (...args: unknown[]) => void): void {
|
||||
event.on(eventName, callback);
|
||||
this.addListenerCleanup(() => event.off(eventName, callback));
|
||||
}
|
||||
|
||||
private watchSetting(settingName: SettingKey, roomId: string | null): void {
|
||||
const watcherRef = SettingsStore.watchSetting(settingName, roomId, this.refreshSnapshot);
|
||||
this.addListenerCleanup(() => SettingsStore.unwatchSetting(watcherRef));
|
||||
}
|
||||
|
||||
private readonly refreshSnapshot = (): void => {
|
||||
this.snapshot.merge(this.computeSnapshot());
|
||||
};
|
||||
|
||||
private resetEventState(): void {
|
||||
this.downloadedBlob = undefined;
|
||||
this.canDownload = true;
|
||||
this.isDownloadLoading = false;
|
||||
}
|
||||
|
||||
private isCurrentDownloadPermissionRequest(requestId: number, mxEvent: MatrixEvent): boolean {
|
||||
return !this.isDisposed && requestId === this.downloadPermissionRequestId && this.props.mxEvent === mxEvent;
|
||||
}
|
||||
|
||||
private updateDownloadPermissionState(requestId: number, mxEvent: MatrixEvent, canDownload: boolean): boolean {
|
||||
if (!this.isCurrentDownloadPermissionRequest(requestId, mxEvent)) return false;
|
||||
this.canDownload = canDownload;
|
||||
this.refreshSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async updateDownloadPermission(requestId: number): Promise<void> {
|
||||
const { mxEvent } = this.props;
|
||||
const hints = ModuleApi.instance.customComponents.getHintsForMessage(mxEvent);
|
||||
|
||||
if (!hints?.allowDownloadingMedia) {
|
||||
this.updateDownloadPermissionState(requestId, mxEvent, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.updateDownloadPermissionState(requestId, mxEvent, false)) return;
|
||||
|
||||
try {
|
||||
const canDownload = await hints.allowDownloadingMedia();
|
||||
this.updateDownloadPermissionState(requestId, mxEvent, canDownload);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to check media download permission for ${mxEvent.getId()}`, err);
|
||||
this.updateDownloadPermissionState(requestId, mxEvent, false);
|
||||
}
|
||||
}
|
||||
|
||||
private isCurrentDownloadRequest(requestId: number, mxEvent: MatrixEvent): boolean {
|
||||
return !this.isDisposed && requestId === this.downloadRequestId && this.props.mxEvent === mxEvent;
|
||||
}
|
||||
|
||||
private setDownloadLoading(requestId: number, mxEvent: MatrixEvent, isDownloadLoading: boolean): boolean {
|
||||
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return false;
|
||||
this.isDownloadLoading = isDownloadLoading;
|
||||
this.refreshSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly onRoomEvent = (event?: MatrixEvent): void => {
|
||||
if (!event) return;
|
||||
if (event.getType() !== EventType.RoomPinnedEvents && event.getType() !== EventType.RoomJoinRules) return;
|
||||
this.refreshSnapshot();
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs an action against the failed event variant that is still actionable.
|
||||
*/
|
||||
private runActionOnFailedEv(fn: (ev: MatrixEvent) => void, checkFn?: (ev: MatrixEvent) => boolean): void {
|
||||
const shouldUseEvent = checkFn ?? (() => true);
|
||||
const { mxEvent } = this.props;
|
||||
const tryOrder = [mxEvent.localRedactionEvent(), mxEvent.replacingEvent(), mxEvent];
|
||||
|
||||
for (const event of tryOrder) {
|
||||
if (event && shouldUseEvent(event)) {
|
||||
fn(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates props, refreshes listeners when the event changes, and rebuilds the snapshot. */
|
||||
public setProps(newProps: Partial<EventTileActionBarViewModelProps>): void {
|
||||
const prevEvent = this.props.mxEvent;
|
||||
const prevRoomId = prevEvent.getRoomId();
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
...newProps,
|
||||
};
|
||||
|
||||
if (this.props.mxEvent !== prevEvent || this.props.mxEvent.getRoomId() !== prevRoomId) {
|
||||
this.resetEventState();
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
this.refreshSnapshot();
|
||||
}
|
||||
|
||||
/** Removes listeners and releases resources owned by the view model. */
|
||||
public override dispose(): void {
|
||||
this.teardownListeners();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/** Starts a reply to the current event. */
|
||||
public onReplyClick = (_anchor: HTMLElement | null): void => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "reply_to_event",
|
||||
event: this.props.mxEvent,
|
||||
context: this.props.timelineRenderingType,
|
||||
});
|
||||
};
|
||||
|
||||
/** Opens the edit composer for the current event. */
|
||||
public onEditClick = (_anchor: HTMLElement | null): void => {
|
||||
editEvent(
|
||||
MatrixClientPeg.safeGet(),
|
||||
this.props.mxEvent,
|
||||
this.props.timelineRenderingType,
|
||||
this.props.getRelationsForEvent,
|
||||
);
|
||||
};
|
||||
|
||||
/** Retries sending the failed event variant that is still actionable. */
|
||||
public onResendClick = (_anchor: HTMLElement | null): void => {
|
||||
this.runActionOnFailedEv((event) => Resend.resend(MatrixClientPeg.safeGet(), event));
|
||||
};
|
||||
|
||||
/** Cancels the failed event variant that is still cancellable. */
|
||||
public onCancelClick = (_anchor: HTMLElement | null): void => {
|
||||
this.runActionOnFailedEv(
|
||||
(event) => Resend.removeFromQueue(MatrixClientPeg.safeGet(), event),
|
||||
(event) => canCancel(event.status),
|
||||
);
|
||||
};
|
||||
|
||||
/** Pins or unpins the current event. */
|
||||
public onPinClick = async (_anchor: HTMLElement | null): Promise<void> => {
|
||||
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline");
|
||||
};
|
||||
|
||||
/** Downloads the media content for the current event when available. */
|
||||
public onDownloadClick = async (_anchor: HTMLElement | null): Promise<void> => {
|
||||
if (this.isDownloadLoading || !this.canDownload) return;
|
||||
const requestId = ++this.downloadRequestId;
|
||||
const { mxEvent } = this.props;
|
||||
|
||||
try {
|
||||
if (!this.setDownloadLoading(requestId, mxEvent, true)) return;
|
||||
const mediaEventHelper = new MediaEventHelper(mxEvent);
|
||||
|
||||
if (!this.downloadedBlob) {
|
||||
const downloadedBlob = await mediaEventHelper.sourceBlob.value;
|
||||
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return;
|
||||
this.downloadedBlob = downloadedBlob;
|
||||
}
|
||||
|
||||
await this.downloader.download({
|
||||
blob: this.downloadedBlob,
|
||||
name: mediaEventHelper.fileName ?? _t("common|image"),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return;
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("timeline|download_failed"),
|
||||
description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`,
|
||||
});
|
||||
} finally {
|
||||
this.setDownloadLoading(requestId, mxEvent, false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Hides the media preview for the current event. */
|
||||
public onHideClick = (_anchor: HTMLElement | null): void => {
|
||||
void setMediaVisibility(this.props.mxEvent, false);
|
||||
};
|
||||
|
||||
/** Forwards the expand or collapse thread action using the triggering button as the anchor. */
|
||||
public onToggleThreadExpanded = (anchor: HTMLElement | null): void => {
|
||||
this.props.onToggleThreadExpanded?.(anchor);
|
||||
};
|
||||
|
||||
/** Forwards the overflow options action using the triggering button as the anchor. */
|
||||
public onOptionsClick = (anchor: HTMLElement | null): void => {
|
||||
this.props.onOptionsClick?.(anchor);
|
||||
};
|
||||
|
||||
/** Forwards the reactions action using the triggering button as the anchor. */
|
||||
public onReactionsClick = (anchor: HTMLElement | null): void => {
|
||||
this.props.onReactionsClick?.(anchor);
|
||||
};
|
||||
|
||||
/** Opens or starts the thread associated with the current event. */
|
||||
public onReplyInThreadClick = (_anchor: HTMLElement | null): void => {
|
||||
const { mxEvent, isCard } = this.props;
|
||||
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: isCard,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
defaultDispatcher.dispatch<ShowThreadPayload>({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: mxEvent,
|
||||
push: isCard,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* 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 {
|
||||
BaseViewModel,
|
||||
ActionBarAction,
|
||||
type ActionBarViewActions,
|
||||
type ActionBarViewSnapshot,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
/** Props for the thread-list action bar view model. */
|
||||
export interface ThreadListActionBarViewModelProps {
|
||||
/** Called when the view in room action is activated. */
|
||||
onViewInRoomClick?: (anchor: HTMLElement | null) => void;
|
||||
/** Called when the copy link action is activated. */
|
||||
onCopyLinkClick?: (anchor: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
/** View model for the icon-only action bar shown in the thread list. */
|
||||
export class ThreadListActionBarViewModel
|
||||
extends BaseViewModel<ActionBarViewSnapshot, ThreadListActionBarViewModelProps>
|
||||
implements ActionBarViewActions
|
||||
{
|
||||
public constructor(props: ThreadListActionBarViewModelProps) {
|
||||
super(props, {
|
||||
actions: [ActionBarAction.ViewInRoom, ActionBarAction.CopyLink],
|
||||
presentation: "icon",
|
||||
isDownloadEncrypted: false,
|
||||
isDownloadLoading: false,
|
||||
isPinned: false,
|
||||
isQuoteExpanded: false,
|
||||
isThreadReplyAllowed: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Updates the action handlers exposed by the view model. */
|
||||
public setProps(newProps: Partial<ThreadListActionBarViewModelProps>): void {
|
||||
this.props = {
|
||||
...this.props,
|
||||
...newProps,
|
||||
};
|
||||
}
|
||||
|
||||
/** Forwards the view in room action using the triggering button as the anchor. */
|
||||
public onViewInRoomClick = (anchor: HTMLElement | null): void => {
|
||||
this.props.onViewInRoomClick?.(anchor);
|
||||
};
|
||||
|
||||
/** Forwards the copy link action using the triggering button as the anchor. */
|
||||
public onCopyLinkClick = (anchor: HTMLElement | null): void => {
|
||||
this.props.onCopyLinkClick?.(anchor);
|
||||
};
|
||||
}
|
||||
@@ -948,7 +948,10 @@ describe("RoomView", () => {
|
||||
expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.hover(getByText("search term"));
|
||||
const searchResultTile = getByText("search term").closest(".mx_EventTile");
|
||||
expect(searchResultTile).not.toBeNull();
|
||||
|
||||
await userEvent.hover(searchResultTile!);
|
||||
await userEvent.click(await findByLabelText("Edit"));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1014,7 +1017,10 @@ describe("RoomView", () => {
|
||||
});
|
||||
const prom = untilDispatch(Action.ViewRoom, defaultDispatcher);
|
||||
|
||||
await userEvent.hover(getByText("search term"));
|
||||
const searchResultTile = getByText("search term").closest(".mx_EventTile");
|
||||
expect(searchResultTile).not.toBeNull();
|
||||
|
||||
await userEvent.hover(searchResultTile!);
|
||||
await userEvent.click(await findByLabelText("Edit"));
|
||||
|
||||
await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId }));
|
||||
|
||||
+49
-17
@@ -86,15 +86,23 @@ exports[`<MessageEditHistory /> should match the snapshot 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_MessageActionBar"
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
|
||||
role="toolbar"
|
||||
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton"
|
||||
<button
|
||||
aria-label="Remove"
|
||||
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
|
||||
data-kind="tertiary"
|
||||
data-presentation="label"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,15 +232,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_MessageActionBar"
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
|
||||
role="toolbar"
|
||||
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton"
|
||||
<button
|
||||
aria-label="Remove"
|
||||
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
|
||||
data-kind="tertiary"
|
||||
data-presentation="label"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,15 +294,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_MessageActionBar"
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
|
||||
role="toolbar"
|
||||
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton"
|
||||
<button
|
||||
aria-label="Remove"
|
||||
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
|
||||
data-kind="tertiary"
|
||||
data-presentation="label"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,15 +338,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_MessageActionBar"
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
|
||||
role="toolbar"
|
||||
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton"
|
||||
<button
|
||||
aria-label="Remove"
|
||||
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
|
||||
data-kind="tertiary"
|
||||
data-presentation="label"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,7 +364,7 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-describedby="_r_8_"
|
||||
aria-describedby="_r_c_"
|
||||
aria-label="Close dialog"
|
||||
class="mx_AccessibleButton mx_Dialog_cancelButton"
|
||||
role="button"
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024 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 from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { clearAllModals, stubClient } from "../../../../test-utils";
|
||||
import DownloadActionButton from "../../../../../src/components/views/messages/DownloadActionButton";
|
||||
import Modal from "../../../../../src/Modal";
|
||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
|
||||
|
||||
jest.mock("matrix-encrypt-attachment", () => ({
|
||||
decryptAttachment: jest.fn().mockResolvedValue(new Blob(["TESTFILE"], { type: "application/octet-stream" })),
|
||||
}));
|
||||
|
||||
describe("DownloadActionButton", () => {
|
||||
const plainEvent = new MatrixEvent({
|
||||
room_id: "!room:id",
|
||||
sender: "@user:id",
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "test",
|
||||
msgtype: "m.image",
|
||||
url: "mxc://matrix.org/1234",
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAllModals();
|
||||
});
|
||||
|
||||
it("should show error if media API returns one", async () => {
|
||||
const cli = stubClient();
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
mocked(cli.mxcUrlToHttp).mockImplementation(
|
||||
(mxc) => `https://matrix.org/_matrix/media/r0/download/${mxc.slice(6)}`,
|
||||
);
|
||||
|
||||
fetchMock.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Not found" },
|
||||
});
|
||||
|
||||
const mediaEventHelper = new MediaEventHelper(plainEvent);
|
||||
|
||||
render(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => mediaEventHelper} />);
|
||||
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
ErrorDialog,
|
||||
expect.objectContaining({
|
||||
title: "Download failed",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show download tooltip on hover", async () => {
|
||||
stubClient();
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
fetchMock.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", "TESTFILE");
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:id",
|
||||
sender: "@user:id",
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "test",
|
||||
msgtype: "m.image",
|
||||
url: "mxc://matrix.org/1234",
|
||||
},
|
||||
});
|
||||
|
||||
render(<DownloadActionButton mxEvent={event} mediaEventHelperGet={() => undefined} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.hover(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent("Download");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show downloading tooltip while unencrypted files are downloading", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
stubClient();
|
||||
|
||||
fetchMock.getOnce("http://this.is.a.url/matrix.org/1234", "TESTFILE");
|
||||
|
||||
const mediaEventHelper = new MediaEventHelper(plainEvent);
|
||||
|
||||
render(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => mediaEventHelper} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.hover(button);
|
||||
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent("Downloading");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show decrypting tooltip while encrypted files are downloading", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
stubClient();
|
||||
|
||||
fetchMock.getOnce("http://this.is.a.url/matrix.org/1234", "UFTUGJMF");
|
||||
|
||||
const e2eEvent = new MatrixEvent({
|
||||
room_id: "!room:id",
|
||||
sender: "@user:id",
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "test",
|
||||
msgtype: "m.image",
|
||||
file: { url: "mxc://matrix.org/1234" },
|
||||
},
|
||||
});
|
||||
|
||||
const mediaEventHelper = new MediaEventHelper(e2eEvent);
|
||||
|
||||
render(<DownloadActionButton mxEvent={e2eEvent} mediaEventHelperGet={() => mediaEventHelper} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.hover(button);
|
||||
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent("Decrypting");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
Copyright 2024,2025 New Vector Ltd.
|
||||
Copyright 2024 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 from "react";
|
||||
import { fireEvent, render, screen } from "jest-matrix-react";
|
||||
import { MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { HideActionButton } from "../../../../../src/components/views/messages/HideActionButton";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
|
||||
import type { Settings } from "../../../../../src/settings/Settings";
|
||||
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
|
||||
import { getMockClientWithEventEmitter, withClientContextRenderOptions } from "../../../../test-utils";
|
||||
import type { MockedObject } from "jest-mock";
|
||||
|
||||
function mockSetting(mediaPreviews: MediaPreviewValue, showMediaEventIds: Settings["showMediaEventIds"]["default"]) {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
|
||||
if (settingName === "mediaPreviewConfig") {
|
||||
return { media_previews: mediaPreviews, invite_avatars: MediaPreviewValue.Off };
|
||||
} else if (settingName === "showMediaEventIds") {
|
||||
return showMediaEventIds;
|
||||
}
|
||||
throw Error(`Unexpected setting ${settingName}`);
|
||||
});
|
||||
}
|
||||
|
||||
const EVENT_ID = "$foo:bar";
|
||||
|
||||
const event = new MatrixEvent({
|
||||
event_id: EVENT_ID,
|
||||
room_id: "!room:id",
|
||||
sender: "@user:id",
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "test",
|
||||
msgtype: "m.image",
|
||||
url: "mxc://matrix.org/1234",
|
||||
},
|
||||
});
|
||||
|
||||
describe("HideActionButton", () => {
|
||||
let cli: MockedObject<MatrixClient>;
|
||||
beforeEach(() => {
|
||||
cli = getMockClientWithEventEmitter({
|
||||
getRoom: jest.fn(),
|
||||
getUserId: jest.fn(),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it("should show button when event is visible by showMediaEventIds setting", async () => {
|
||||
mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: true });
|
||||
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
|
||||
expect(screen.getByRole("button")).toBeVisible();
|
||||
});
|
||||
it("should show button when event is visible by mediaPreviewConfig setting", async () => {
|
||||
mockSetting(MediaPreviewValue.On, {});
|
||||
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
|
||||
expect(screen.getByRole("button")).toBeVisible();
|
||||
});
|
||||
it("should hide button when event is hidden by showMediaEventIds setting", async () => {
|
||||
mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: false });
|
||||
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
it("should hide button when event is hidden by showImages setting", async () => {
|
||||
mockSetting(MediaPreviewValue.Off, {});
|
||||
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
it("should store event as hidden when clicked", async () => {
|
||||
const spy = jest.spyOn(SettingsStore, "setValue");
|
||||
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(spy).toHaveBeenCalledWith("showMediaEventIds", null, SettingLevel.DEVICE, { "$foo:bar": false });
|
||||
// Button should be hidden after the setting is set.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,564 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022, 2023 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 from "react";
|
||||
import { act, render, fireEvent, screen, waitFor } from "jest-matrix-react";
|
||||
import {
|
||||
EventType,
|
||||
EventStatus,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
MsgType,
|
||||
Room,
|
||||
FeatureSupport,
|
||||
Thread,
|
||||
EventTimeline,
|
||||
RoomStateEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import MessageActionBar from "../../../../../src/components/views/messages/MessageActionBar";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsUser,
|
||||
mockClientMethodsEvents,
|
||||
makeBeaconInfoEvent,
|
||||
} from "../../../../test-utils";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import RoomContext, { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import PinningUtils from "../../../../../src/utils/PinningUtils";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
|
||||
jest.mock("../../../../../src/dispatcher/dispatcher");
|
||||
|
||||
describe("<MessageActionBar />", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const roomId = "!room:server.org";
|
||||
|
||||
const client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
...mockClientMethodsEvents(),
|
||||
getRoom: jest.fn(),
|
||||
setRoomAccountData: jest.fn(),
|
||||
sendStateEvent: jest.fn(),
|
||||
});
|
||||
const room = new Room(roomId, client, userId);
|
||||
|
||||
const alicesMessageEvent = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "Hello",
|
||||
},
|
||||
event_id: "$alices_message",
|
||||
});
|
||||
|
||||
const bobsMessageEvent = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: "@bob:server.org",
|
||||
room_id: roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "I am bob",
|
||||
},
|
||||
event_id: "$bobs_message",
|
||||
});
|
||||
|
||||
const redactedEvent = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
});
|
||||
redactedEvent.makeRedacted(redactedEvent, room);
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, any> = {};
|
||||
return {
|
||||
getItem: jest.fn().mockImplementation((key) => store[key] ?? null),
|
||||
setItem: jest.fn().mockImplementation((key, value) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
clear: jest.fn().mockImplementation(() => {
|
||||
store = {};
|
||||
}),
|
||||
removeItem: jest.fn().mockImplementation((key) => delete store[key]),
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
|
||||
|
||||
client.getRoom.mockReturnValue(room);
|
||||
|
||||
const defaultProps = {
|
||||
getTile: jest.fn(),
|
||||
getReplyChain: jest.fn(),
|
||||
toggleThreadExpanded: jest.fn(),
|
||||
mxEvent: alicesMessageEvent,
|
||||
permalinkCreator: new RoomPermalinkCreator(room),
|
||||
};
|
||||
const defaultRoomContext = {
|
||||
...RoomContext,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
canSendMessages: true,
|
||||
canReact: true,
|
||||
room,
|
||||
} as unknown as RoomContextType;
|
||||
const getComponent = (props = {}, roomContext: Partial<RoomContextType> = {}) =>
|
||||
render(
|
||||
<ScopedRoomContextProvider {...defaultRoomContext} {...roomContext}>
|
||||
<MessageActionBar {...defaultProps} {...props} />
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The base case is that we have received the remote echo and have an eventId. No sending status.
|
||||
alicesMessageEvent.setStatus(null);
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
||||
jest.spyOn(SettingsStore, "setValue").mockRestore();
|
||||
});
|
||||
|
||||
it("kills event listeners on unmount", () => {
|
||||
const offSpy = jest.spyOn(alicesMessageEvent, "off").mockClear();
|
||||
const wrapper = getComponent({ mxEvent: alicesMessageEvent });
|
||||
|
||||
act(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
expect(offSpy.mock.calls[0][0]).toEqual(MatrixEventEvent.Status);
|
||||
expect(offSpy.mock.calls[1][0]).toEqual(MatrixEventEvent.Decrypted);
|
||||
expect(offSpy.mock.calls[2][0]).toEqual(MatrixEventEvent.BeforeRedaction);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("decryption", () => {
|
||||
it("decrypts event if needed", () => {
|
||||
getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates component on decrypted event", () => {
|
||||
const decryptingEvent = new MatrixEvent({
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {},
|
||||
});
|
||||
jest.spyOn(decryptingEvent, "isBeingDecrypted").mockReturnValue(true);
|
||||
const { queryByLabelText } = getComponent({ mxEvent: decryptingEvent });
|
||||
|
||||
// still encrypted event is not actionable => no reply button
|
||||
expect(queryByLabelText("Reply")).toBeFalsy();
|
||||
|
||||
act(() => {
|
||||
// ''decrypt'' the event
|
||||
decryptingEvent.event.type = alicesMessageEvent.getType();
|
||||
decryptingEvent.event.content = alicesMessageEvent.getContent();
|
||||
decryptingEvent.emit(MatrixEventEvent.Decrypted, decryptingEvent);
|
||||
});
|
||||
|
||||
// new available actions after decryption
|
||||
expect(queryByLabelText("Reply")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("status", () => {
|
||||
it("updates component when event status changes", () => {
|
||||
alicesMessageEvent.setStatus(EventStatus.QUEUED);
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
|
||||
// pending event status, cancel action available
|
||||
expect(queryByLabelText("Delete")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
alicesMessageEvent.setStatus(EventStatus.SENT);
|
||||
});
|
||||
|
||||
// event is sent, no longer cancelable
|
||||
expect(queryByLabelText("Delete")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redaction", () => {
|
||||
// this doesn't do what it's supposed to
|
||||
// because beforeRedaction event is fired... before redaction
|
||||
// event is unchanged at point when this component updates
|
||||
// TODO file bug
|
||||
it.skip("updates component on before redaction event", () => {
|
||||
const event = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "Hello",
|
||||
},
|
||||
});
|
||||
const { queryByLabelText } = getComponent({ mxEvent: event });
|
||||
|
||||
// no pending redaction => no delete button
|
||||
expect(queryByLabelText("Delete")).toBeFalsy();
|
||||
|
||||
act(() => {
|
||||
const redactionEvent = new MatrixEvent({
|
||||
type: EventType.RoomRedaction,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
});
|
||||
redactionEvent.setStatus(EventStatus.QUEUED);
|
||||
event.markLocallyRedacted(redactionEvent);
|
||||
});
|
||||
|
||||
// updated with local redaction event, delete now available
|
||||
expect(queryByLabelText("Delete")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("options button", () => {
|
||||
it("renders options menu", () => {
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(queryByLabelText("Options")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens message context menu on click", () => {
|
||||
const { getByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
fireEvent.click(queryByLabelText("Options")!);
|
||||
expect(getByTestId("mx_MessageContextMenu")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reply button", () => {
|
||||
it("renders reply button on own actionable event", () => {
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(queryByLabelText("Reply")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders reply button on others actionable event", () => {
|
||||
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent }, { canSendMessages: true });
|
||||
expect(queryByLabelText("Reply")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render reply button on non-actionable event", () => {
|
||||
// redacted event is not actionable
|
||||
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
|
||||
expect(queryByLabelText("Reply")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("does not render reply button when user cannot send messaged", () => {
|
||||
// redacted event is not actionable
|
||||
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canSendMessages: false });
|
||||
expect(queryByLabelText("Reply")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("dispatches reply event on click", () => {
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
|
||||
fireEvent.click(queryByLabelText("Reply")!);
|
||||
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: "reply_to_event",
|
||||
event: alicesMessageEvent,
|
||||
context: TimelineRenderingType.Room,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("react button", () => {
|
||||
it("renders react button on own actionable event", () => {
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(queryByLabelText("React")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders react button on others actionable event", () => {
|
||||
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent });
|
||||
expect(queryByLabelText("React")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render react button on non-actionable event", () => {
|
||||
// redacted event is not actionable
|
||||
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
|
||||
expect(queryByLabelText("React")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("does not render react button when user cannot react", () => {
|
||||
// redacted event is not actionable
|
||||
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canReact: false });
|
||||
expect(queryByLabelText("React")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("opens reaction picker on click", () => {
|
||||
const { queryByLabelText, getByTestId } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
fireEvent.click(queryByLabelText("React")!);
|
||||
expect(getByTestId("mx_EmojiPicker")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel button", () => {
|
||||
it("renders cancel button for an event with a cancelable status", () => {
|
||||
alicesMessageEvent.setStatus(EventStatus.QUEUED);
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(queryByLabelText("Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders cancel button for an event with a pending edit", () => {
|
||||
const event = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "Hello",
|
||||
},
|
||||
});
|
||||
event.setStatus(EventStatus.SENT);
|
||||
const replacingEvent = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "replacing event body",
|
||||
},
|
||||
});
|
||||
replacingEvent.setStatus(EventStatus.QUEUED);
|
||||
event.makeReplaced(replacingEvent);
|
||||
const { queryByLabelText } = getComponent({ mxEvent: event });
|
||||
expect(queryByLabelText("Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders cancel button for an event with a pending redaction", () => {
|
||||
const event = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "Hello",
|
||||
},
|
||||
});
|
||||
event.setStatus(EventStatus.SENT);
|
||||
|
||||
const redactionEvent = new MatrixEvent({
|
||||
type: EventType.RoomRedaction,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
});
|
||||
redactionEvent.setStatus(EventStatus.QUEUED);
|
||||
|
||||
event.markLocallyRedacted(redactionEvent);
|
||||
const { queryByLabelText } = getComponent({ mxEvent: event });
|
||||
expect(queryByLabelText("Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders cancel and retry button for an event with NOT_SENT status", () => {
|
||||
alicesMessageEvent.setStatus(EventStatus.NOT_SENT);
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(queryByLabelText("Retry")).toBeTruthy();
|
||||
expect(queryByLabelText("Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("only shows retry and delete buttons when event could not be sent", () => {
|
||||
// Enable pin and other features
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
||||
|
||||
alicesMessageEvent.setStatus(EventStatus.NOT_SENT);
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
|
||||
// Should show retry and cancel buttons
|
||||
expect(queryByLabelText("Retry")).toBeTruthy();
|
||||
expect(queryByLabelText("Delete")).toBeTruthy();
|
||||
|
||||
// Should NOT show edit, pin, react, reply buttons
|
||||
expect(queryByLabelText("Edit")).toBeFalsy();
|
||||
expect(queryByLabelText("Pin")).toBeFalsy();
|
||||
expect(queryByLabelText("React")).toBeFalsy();
|
||||
expect(queryByLabelText("Reply")).toBeFalsy();
|
||||
expect(queryByLabelText("Reply in thread")).toBeFalsy();
|
||||
});
|
||||
|
||||
it.todo("unsends event on cancel click");
|
||||
it.todo("retrys event on retry click");
|
||||
});
|
||||
|
||||
describe("thread button", () => {
|
||||
beforeEach(() => {
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
});
|
||||
|
||||
describe("when threads feature is enabled", () => {
|
||||
it("renders thread button on own actionable event", () => {
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(queryByLabelText("Reply in thread")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render thread button for a beacon_info event", () => {
|
||||
const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId);
|
||||
const { queryByLabelText } = getComponent({ mxEvent: beaconInfoEvent });
|
||||
expect(queryByLabelText("Reply in thread")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("opens thread on click", () => {
|
||||
const { getByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
|
||||
fireEvent.click(getByLabelText("Reply in thread"));
|
||||
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: alicesMessageEvent,
|
||||
push: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens parent thread for a thread reply message", () => {
|
||||
const threadReplyEvent = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "this is a thread reply",
|
||||
},
|
||||
});
|
||||
// mock the thread stuff
|
||||
jest.spyOn(threadReplyEvent, "isThreadRoot", "get").mockReturnValue(false);
|
||||
// set alicesMessageEvent as the root event
|
||||
jest.spyOn(threadReplyEvent, "getThread").mockReturnValue({
|
||||
rootEvent: alicesMessageEvent,
|
||||
} as unknown as Thread);
|
||||
const { getByLabelText } = getComponent({ mxEvent: threadReplyEvent });
|
||||
|
||||
fireEvent.click(getByLabelText("Reply in thread"));
|
||||
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: alicesMessageEvent,
|
||||
initialEvent: threadReplyEvent,
|
||||
highlighted: true,
|
||||
scroll_into_view: true,
|
||||
push: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([["React"], ["Reply"], ["Reply in thread"], ["Edit"], ["Pin"]])(
|
||||
"does not show context menu when right-clicking",
|
||||
(buttonLabel: string) => {
|
||||
// For favourite and pin buttons
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
||||
|
||||
const event = new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
event.stopPropagation = jest.fn();
|
||||
event.preventDefault = jest.fn();
|
||||
|
||||
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
fireEvent(queryByLabelText(buttonLabel)!, event);
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(queryByTestId("mx_MessageContextMenu")).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
it("does shows context menu when right-clicking options", () => {
|
||||
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
fireEvent.contextMenu(queryByLabelText("Options")!);
|
||||
expect(queryByTestId("mx_MessageContextMenu")).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("pin button", () => {
|
||||
beforeEach(() => {
|
||||
// enable pin button
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.spyOn(
|
||||
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||
"mayClientSendStateEvent",
|
||||
).mockRestore();
|
||||
});
|
||||
|
||||
it("should not render pin button when user can't send state event", () => {
|
||||
jest.spyOn(
|
||||
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||
"mayClientSendStateEvent",
|
||||
).mockReturnValue(false);
|
||||
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(queryByLabelText("Pin")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should render pin button", () => {
|
||||
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(queryByLabelText("Pin")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should listen to room pinned events", async () => {
|
||||
getComponent({ mxEvent: alicesMessageEvent });
|
||||
expect(screen.getByLabelText("Pin")).toBeInTheDocument();
|
||||
|
||||
// Event is considered pinned
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
// Emit that the room pinned events have changed
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
roomState.emit(
|
||||
RoomStateEvent.Events,
|
||||
{
|
||||
getType: () => EventType.RoomPinnedEvents,
|
||||
} as MatrixEvent,
|
||||
roomState,
|
||||
null,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("Unpin")).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe("expand/collapse quote buttons", () => {
|
||||
it.each([
|
||||
["expand", false],
|
||||
["collapse", true],
|
||||
])("should render %s", (state, value) => {
|
||||
const { getByLabelText } = getComponent({
|
||||
mxEvent: new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
sender: userId,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
"msgtype": MsgType.Text,
|
||||
"body": "Hello",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": { event_id: alicesMessageEvent.getId() },
|
||||
},
|
||||
},
|
||||
event_id: "$alices_reply",
|
||||
}),
|
||||
isQuoteExpanded: value,
|
||||
});
|
||||
expect(getByLabelText(`${state[0].toUpperCase()}${state.slice(1)} quotes`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 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 { getByLabelText, render, type RenderResult } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React, { type ComponentProps } from "react";
|
||||
|
||||
import { EventTileThreadToolbar } from "../../../../../../src/components/views/rooms/EventTile/EventTileThreadToolbar";
|
||||
|
||||
describe("EventTileThreadToolbar", () => {
|
||||
const viewInRoom = jest.fn();
|
||||
const copyLink = jest.fn();
|
||||
|
||||
function renderComponent(props: Partial<ComponentProps<typeof EventTileThreadToolbar>> = {}): RenderResult {
|
||||
return render(<EventTileThreadToolbar viewInRoom={viewInRoom} copyLinkToThread={copyLink} {...props} />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
const { asFragment } = renderComponent();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("calls the right callbacks", async () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
const copyBtn = getByLabelText(container, "Copy link to thread");
|
||||
const viewInRoomBtn = getByLabelText(container, "View in room");
|
||||
|
||||
await userEvent.click(copyBtn);
|
||||
expect(copyLink).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(viewInRoomBtn);
|
||||
expect(viewInRoom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`EventTileThreadToolbar renders 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="mx_MessageActionBar"
|
||||
role="toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="View in room"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 16q1.875 0 3.188-1.312Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7 8.813 8.313 7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.787A2.6 2.6 0 0 1 9.3 11.5q0-1.125.787-1.912A2.6 2.6 0 0 1 12 8.8q1.125 0 1.912.787.788.788.788 1.913t-.787 1.912A2.6 2.6 0 0 1 12 14.2m0 4.8q-3.475 0-6.35-1.837Q2.775 15.324 1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.775.8.8 0 0 1 .1-.313q1.475-3.125 4.35-4.962Q8.525 4 12 4t6.35 1.838T22.7 10.8a.8.8 0 0 1 .1.313 3 3 0 0 1 0 .774.8.8 0 0 1-.1.313q-1.475 3.125-4.35 4.963Q15.475 19 12 19m0-2a9.54 9.54 0 0 0 5.188-1.488A9.77 9.77 0 0 0 20.8 11.5a9.77 9.77 0 0 0-3.613-4.012A9.54 9.54 0 0 0 12 6a9.55 9.55 0 0 0-5.187 1.487A9.77 9.77 0 0 0 3.2 11.5a9.77 9.77 0 0 0 3.613 4.012A9.54 9.54 0 0 0 12 17"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Copy link to thread"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 19.071q-1.467 1.467-3.536 1.467-2.067 0-3.535-1.467t-1.467-3.535q0-2.07 1.467-3.536L7.05 9.879q.3-.3.707-.3t.707.3.301.707-.3.707l-2.122 2.121a2.9 2.9 0 0 0-.884 2.122q0 1.237.884 2.12.884.885 2.121.885t2.122-.884l2.121-2.121q.3-.3.707-.3t.707.3.3.707q0 .405-.3.707zm-1.414-4.243q-.3.3-.707.301a.97.97 0 0 1-.707-.3q-.3-.3-.301-.708 0-.405.3-.707l4.243-4.242q.3-.3.707-.3t.707.3.3.707-.3.707zm6.364-.707q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.301-.707 0-.405.3-.707l2.122-2.121q.884-.885.884-2.121 0-1.238-.884-2.122a2.9 2.9 0 0 0-2.121-.884q-1.237 0-2.122.884l-2.121 2.122q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.3-.708 0-.405.3-.707L12 4.93q1.467-1.467 3.536-1.467t3.535 1.467 1.467 3.536T19.071 12z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
+8
-149
@@ -111,53 +111,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
Hey you. You're the best!
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="mx_MessageActionBar"
|
||||
role="toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Edit"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,7 +122,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
<label
|
||||
aria-label="Message bubbles"
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_9_"
|
||||
for="radix-_r_1_"
|
||||
>
|
||||
<div
|
||||
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
|
||||
@@ -179,7 +132,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
>
|
||||
<input
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_9_"
|
||||
id="radix-_r_1_"
|
||||
name="layout"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -252,53 +205,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
Hey you. You're the best!
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="mx_MessageActionBar"
|
||||
role="toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Edit"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,7 +216,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
<label
|
||||
aria-label="IRC (experimental)"
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_i_"
|
||||
for="radix-_r_2_"
|
||||
>
|
||||
<div
|
||||
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
|
||||
@@ -320,7 +226,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
>
|
||||
<input
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_i_"
|
||||
id="radix-_r_2_"
|
||||
name="layout"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -396,53 +302,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
Hey you. You're the best!
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="mx_MessageActionBar"
|
||||
role="toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Edit"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,9 +321,9 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
class="_container_udcm8_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_s_"
|
||||
aria-describedby="radix-_r_4_"
|
||||
class="_input_udcm8_24"
|
||||
id="radix-_r_r_"
|
||||
id="radix-_r_3_"
|
||||
name="compactLayout"
|
||||
role="switch"
|
||||
title=""
|
||||
@@ -480,13 +339,13 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_r_"
|
||||
for="radix-_r_3_"
|
||||
>
|
||||
Show compact text and messages
|
||||
</label>
|
||||
<span
|
||||
class="_message_19upo_85 _help-message_19upo_91"
|
||||
id="radix-_r_s_"
|
||||
id="radix-_r_4_"
|
||||
>
|
||||
Modern layout must be selected to use this feature.
|
||||
</span>
|
||||
|
||||
+8
-149
@@ -254,53 +254,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
Hey you. You're the best!
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="mx_MessageActionBar"
|
||||
role="toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Edit"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +265,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
<label
|
||||
aria-label="Message bubbles"
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_c_"
|
||||
for="radix-_r_4_"
|
||||
>
|
||||
<div
|
||||
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
|
||||
@@ -322,7 +275,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<input
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_c_"
|
||||
id="radix-_r_4_"
|
||||
name="layout"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -395,53 +348,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
Hey you. You're the best!
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="mx_MessageActionBar"
|
||||
role="toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Edit"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,7 +359,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
<label
|
||||
aria-label="IRC (experimental)"
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_l_"
|
||||
for="radix-_r_5_"
|
||||
>
|
||||
<div
|
||||
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
|
||||
@@ -463,7 +369,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<input
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_l_"
|
||||
id="radix-_r_5_"
|
||||
name="layout"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -539,53 +445,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
Hey you. You're the best!
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Message Actions"
|
||||
aria-live="off"
|
||||
class="mx_MessageActionBar"
|
||||
role="toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Edit"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -605,9 +464,9 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
class="_container_udcm8_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_v_"
|
||||
aria-describedby="radix-_r_7_"
|
||||
class="_input_udcm8_24"
|
||||
id="radix-_r_u_"
|
||||
id="radix-_r_6_"
|
||||
name="compactLayout"
|
||||
role="switch"
|
||||
title=""
|
||||
@@ -623,13 +482,13 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_u_"
|
||||
for="radix-_r_6_"
|
||||
>
|
||||
Show compact text and messages
|
||||
</label>
|
||||
<span
|
||||
class="_message_19upo_85 _help-message_19upo_91"
|
||||
id="radix-_r_v_"
|
||||
id="radix-_r_7_"
|
||||
>
|
||||
Modern layout must be selected to use this feature.
|
||||
</span>
|
||||
|
||||
@@ -35,6 +35,11 @@ describe("useMediaVisible", () => {
|
||||
withClientContextRenderOptions(matrixClient),
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithoutEvent() {
|
||||
return renderHook(() => useMediaVisible(), withClientContextRenderOptions(matrixClient));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = createTestClient();
|
||||
room = mkStubRoom(ROOM_ID, undefined, matrixClient);
|
||||
@@ -57,6 +62,14 @@ describe("useMediaVisible", () => {
|
||||
expect(visible).toEqual(true);
|
||||
});
|
||||
|
||||
it("should use the global rule when no event is provided", () => {
|
||||
mediaPreviewConfig.media_previews = MediaPreviewValue.Off;
|
||||
expect(renderWithoutEvent().result.current[0]).toEqual(false);
|
||||
|
||||
mediaPreviewConfig.media_previews = MediaPreviewValue.On;
|
||||
expect(renderWithoutEvent().result.current[0]).toEqual(true);
|
||||
});
|
||||
|
||||
it("should hide media when media previews are Off", () => {
|
||||
mediaPreviewConfig.media_previews = MediaPreviewValue.Off;
|
||||
const [visible] = render().result.current;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* 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 { ActionBarAction } from "@element-hq/web-shared-components";
|
||||
|
||||
import { EditHistoryActionBarViewModel } from "../../../src/viewmodels/message-body/EditHistoryActionBarViewModel";
|
||||
|
||||
describe("EditHistoryActionBarViewModel", () => {
|
||||
it("builds a label snapshot with remove and view source actions", () => {
|
||||
const vm = new EditHistoryActionBarViewModel({
|
||||
canRemove: true,
|
||||
showViewSource: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
actions: [ActionBarAction.Remove, ActionBarAction.ViewSource],
|
||||
presentation: "label",
|
||||
isDownloadEncrypted: false,
|
||||
isDownloadLoading: false,
|
||||
isPinned: false,
|
||||
isQuoteExpanded: false,
|
||||
isThreadReplyAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits actions that are disabled by props", () => {
|
||||
const vm = new EditHistoryActionBarViewModel({
|
||||
canRemove: false,
|
||||
showViewSource: false,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates the snapshot when props change", () => {
|
||||
const vm = new EditHistoryActionBarViewModel({
|
||||
canRemove: false,
|
||||
showViewSource: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().actions).toEqual([ActionBarAction.ViewSource]);
|
||||
|
||||
vm.setProps({
|
||||
canRemove: true,
|
||||
showViewSource: false,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().actions).toEqual([ActionBarAction.Remove]);
|
||||
});
|
||||
|
||||
it("forwards remove clicks to props", () => {
|
||||
const onRemoveClick = jest.fn();
|
||||
const vm = new EditHistoryActionBarViewModel({
|
||||
canRemove: true,
|
||||
showViewSource: false,
|
||||
onRemoveClick,
|
||||
});
|
||||
const anchor = document.createElement("button");
|
||||
|
||||
vm.onRemoveClick(anchor);
|
||||
|
||||
expect(onRemoveClick).toHaveBeenCalledWith(anchor);
|
||||
});
|
||||
|
||||
it("forwards view source clicks to props", () => {
|
||||
const onViewSourceClick = jest.fn();
|
||||
const vm = new EditHistoryActionBarViewModel({
|
||||
canRemove: false,
|
||||
showViewSource: true,
|
||||
onViewSourceClick,
|
||||
});
|
||||
const anchor = document.createElement("button");
|
||||
|
||||
vm.onViewSourceClick(anchor);
|
||||
|
||||
expect(onViewSourceClick).toHaveBeenCalledWith(anchor);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,716 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* 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 EventEmitter from "events";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
import { mocked } from "jest-mock";
|
||||
import {
|
||||
EventStatus,
|
||||
EventTimeline,
|
||||
EventType,
|
||||
M_BEACON_INFO,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
MsgType,
|
||||
RelationType,
|
||||
RoomStateEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { ActionBarAction } from "@element-hq/web-shared-components";
|
||||
|
||||
import {
|
||||
EventTileActionBarViewModel,
|
||||
type EventTileActionBarViewModelProps,
|
||||
} from "../../../src/viewmodels/room/EventTileActionBarViewModel";
|
||||
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import Resend from "../../../src/Resend";
|
||||
import PinningUtils from "../../../src/utils/PinningUtils";
|
||||
import PosthogTrackers from "../../../src/PosthogTrackers";
|
||||
import Modal from "../../../src/Modal";
|
||||
import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { ModuleApi } from "../../../src/modules/Api";
|
||||
import { canCancel, canEditContent, editEvent, isContentActionable } from "../../../src/utils/EventUtils";
|
||||
import { shouldDisplayReply } from "../../../src/utils/Reply";
|
||||
import { MediaEventHelper } from "../../../src/utils/MediaEventHelper";
|
||||
import { getMediaVisibility, setMediaVisibility } from "../../../src/utils/media/mediaVisibility";
|
||||
import { createTestClient } from "../../test-utils";
|
||||
|
||||
jest.mock("../../../src/dispatcher/dispatcher", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
dispatch: jest.fn(),
|
||||
register: jest.fn().mockReturnValue("dispatcher-ref"),
|
||||
unregister: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/Resend", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
resend: jest.fn(),
|
||||
removeFromQueue: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/PosthogTrackers", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
trackPinUnpinMessage: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/Modal", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
createDialog: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/languageHandler", () => ({
|
||||
_t: (key: string) => {
|
||||
switch (key) {
|
||||
case "timeline|download_failed":
|
||||
return "Download failed";
|
||||
case "timeline|download_failed_description":
|
||||
return "Failed to download file";
|
||||
case "common|image":
|
||||
return "Image";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
_td: (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/utils/EventUtils", () => ({
|
||||
canCancel: jest.fn(),
|
||||
canEditContent: jest.fn(),
|
||||
editEvent: jest.fn(),
|
||||
isContentActionable: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/utils/PinningUtils", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
canPin: jest.fn(),
|
||||
canUnpin: jest.fn(),
|
||||
isPinned: jest.fn(),
|
||||
pinOrUnpinEvent: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/utils/Reply", () => ({
|
||||
shouldDisplayReply: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/utils/media/mediaVisibility", () => ({
|
||||
getMediaVisibility: jest.fn(),
|
||||
setMediaVisibility: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDownload = jest.fn();
|
||||
jest.mock("../../../src/utils/FileDownloader", () => ({
|
||||
FileDownloader: jest.fn().mockImplementation(() => ({
|
||||
download: mockDownload,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("EventTileActionBarViewModel", () => {
|
||||
const userId = "@alice:example.org";
|
||||
const roomId = "!room:example.org";
|
||||
const rootEvent = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
room_id: roomId,
|
||||
sender: "@root:example.org",
|
||||
event_id: "$root",
|
||||
content: { msgtype: MsgType.Text, body: "Root" },
|
||||
});
|
||||
|
||||
let client: ReturnType<typeof createTestClient>;
|
||||
let roomState: EventEmitter;
|
||||
let room: {
|
||||
getLiveTimeline: jest.Mock;
|
||||
};
|
||||
let getHintsForMessageSpy: jest.SpyInstance;
|
||||
|
||||
const createMessageEvent = (overrides: Partial<ConstructorParameters<typeof MatrixEvent>[0]> = {}): MatrixEvent =>
|
||||
new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
room_id: roomId,
|
||||
sender: userId,
|
||||
event_id: "$event",
|
||||
content: { msgtype: MsgType.Text, body: "Hello" },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createVm = (props: Partial<EventTileActionBarViewModelProps> = {}): EventTileActionBarViewModel => {
|
||||
const mxEvent = props.mxEvent ?? createMessageEvent();
|
||||
return new EventTileActionBarViewModel({
|
||||
mxEvent,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
canSendMessages: true,
|
||||
canReact: true,
|
||||
...props,
|
||||
});
|
||||
};
|
||||
|
||||
const createPendingPromise = <T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
} => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client = createTestClient();
|
||||
roomState = new EventEmitter();
|
||||
room = {
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest
|
||||
.fn()
|
||||
.mockImplementation((dir) => (dir === EventTimeline.FORWARDS ? roomState : undefined)),
|
||||
}),
|
||||
};
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room as never);
|
||||
jest.spyOn(client, "decryptEventIfNeeded");
|
||||
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((name, scope) => `${name}:${scope ?? "global"}`);
|
||||
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(() => {});
|
||||
|
||||
mocked(canCancel).mockImplementation((status) => status === EventStatus.QUEUED);
|
||||
mocked(canEditContent).mockReturnValue(true);
|
||||
mocked(isContentActionable).mockReturnValue(true);
|
||||
mocked(shouldDisplayReply).mockReturnValue(true);
|
||||
mocked(getMediaVisibility).mockReturnValue(true);
|
||||
mocked(setMediaVisibility).mockResolvedValue(undefined);
|
||||
mocked(PinningUtils.canPin).mockReturnValue(false);
|
||||
mocked(PinningUtils.canUnpin).mockReturnValue(false);
|
||||
mocked(PinningUtils.isPinned).mockReturnValue(false);
|
||||
mocked(PinningUtils.pinOrUnpinEvent).mockResolvedValue(undefined);
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(false);
|
||||
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(false);
|
||||
mockDownload.mockResolvedValue(undefined);
|
||||
|
||||
getHintsForMessageSpy = jest.spyOn(ModuleApi.instance.customComponents, "getHintsForMessage");
|
||||
getHintsForMessageSpy.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getHintsForMessageSpy.mockRestore();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("builds the snapshot for an actionable message", async () => {
|
||||
const vm = createVm({ isQuoteExpanded: true });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
actions: [
|
||||
ActionBarAction.React,
|
||||
ActionBarAction.Reply,
|
||||
ActionBarAction.ReplyInThread,
|
||||
ActionBarAction.Edit,
|
||||
ActionBarAction.Expand,
|
||||
ActionBarAction.Options,
|
||||
],
|
||||
presentation: "icon",
|
||||
isDownloadEncrypted: false,
|
||||
isDownloadLoading: false,
|
||||
isPinned: false,
|
||||
isQuoteExpanded: true,
|
||||
isThreadReplyAllowed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reacts to media download permission hints and room state updates", async () => {
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
|
||||
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(true);
|
||||
getHintsForMessageSpy.mockReturnValue({
|
||||
allowDownloadingMedia: jest.fn().mockResolvedValue(true),
|
||||
} as never);
|
||||
|
||||
const vm = createVm({
|
||||
mxEvent: createMessageEvent({
|
||||
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
|
||||
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Hide);
|
||||
|
||||
await waitFor(() => expect(vm.getSnapshot().actions).toContain(ActionBarAction.Download));
|
||||
|
||||
mocked(PinningUtils.isPinned).mockReturnValue(true);
|
||||
roomState.emit(
|
||||
RoomStateEvent.Events,
|
||||
new MatrixEvent({
|
||||
type: EventType.RoomPinnedEvents,
|
||||
room_id: roomId,
|
||||
sender: userId,
|
||||
content: { pinned: ["$event"] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vm.getSnapshot().isPinned).toBe(true);
|
||||
|
||||
mocked(getMediaVisibility).mockReturnValue(false);
|
||||
roomState.emit(
|
||||
RoomStateEvent.Events,
|
||||
new MatrixEvent({
|
||||
type: EventType.RoomJoinRules,
|
||||
room_id: roomId,
|
||||
sender: userId,
|
||||
content: { join_rule: "public" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Hide);
|
||||
});
|
||||
|
||||
it("ignores stale download permission results after setProps changes the event", async () => {
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
|
||||
const permissionA = createPendingPromise<boolean>();
|
||||
const permissionB = createPendingPromise<boolean>();
|
||||
const eventA = createMessageEvent({
|
||||
event_id: "$eventA",
|
||||
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
|
||||
});
|
||||
const eventB = createMessageEvent({
|
||||
event_id: "$eventB",
|
||||
content: { msgtype: MsgType.Image, body: "Image B", url: "mxc://example.org/b" },
|
||||
});
|
||||
|
||||
getHintsForMessageSpy.mockImplementation((event) => {
|
||||
if (event === eventA) {
|
||||
return {
|
||||
allowDownloadingMedia: jest.fn().mockReturnValue(permissionA.promise),
|
||||
} as never;
|
||||
}
|
||||
|
||||
if (event === eventB) {
|
||||
return {
|
||||
allowDownloadingMedia: jest.fn().mockReturnValue(permissionB.promise),
|
||||
} as never;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const vm = createVm({ mxEvent: eventA });
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
|
||||
|
||||
vm.setProps({ mxEvent: eventB });
|
||||
permissionA.resolve(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
|
||||
|
||||
permissionB.resolve(false);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
|
||||
});
|
||||
|
||||
it("refreshes on event status changes and removes listeners on dispose", () => {
|
||||
const mxEvent = createMessageEvent();
|
||||
const offSpy = jest.spyOn(mxEvent, "off");
|
||||
const roomStateOffSpy = jest.spyOn(roomState, "off");
|
||||
const vm = createVm({ mxEvent });
|
||||
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Cancel);
|
||||
|
||||
mxEvent.setStatus(EventStatus.QUEUED);
|
||||
|
||||
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Cancel);
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent);
|
||||
|
||||
vm.dispose();
|
||||
|
||||
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Status, expect.any(Function));
|
||||
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function));
|
||||
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.BeforeRedaction, expect.any(Function));
|
||||
expect(roomStateOffSpy).toHaveBeenCalledWith(RoomStateEvent.Events, expect.any(Function));
|
||||
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("mediaPreviewConfig:!room:example.org");
|
||||
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("showMediaEventIds:global");
|
||||
});
|
||||
|
||||
it("routes resend and cancel actions to the actionable failed event variant", () => {
|
||||
const mxEvent = createMessageEvent();
|
||||
const localRedactionEvent = createMessageEvent({ event_id: "$redaction" });
|
||||
const replacingEvent = createMessageEvent({ event_id: "$replacement" });
|
||||
|
||||
localRedactionEvent.setStatus(EventStatus.SENT);
|
||||
replacingEvent.setStatus(EventStatus.QUEUED);
|
||||
|
||||
jest.spyOn(mxEvent, "localRedactionEvent").mockReturnValue(localRedactionEvent);
|
||||
jest.spyOn(mxEvent, "replacingEvent").mockReturnValue(replacingEvent);
|
||||
|
||||
const vm = createVm({ mxEvent });
|
||||
|
||||
vm.onResendClick(null);
|
||||
vm.onCancelClick(null);
|
||||
|
||||
expect(Resend.resend).toHaveBeenCalledWith(client, localRedactionEvent);
|
||||
expect(Resend.removeFromQueue).toHaveBeenCalledWith(client, replacingEvent);
|
||||
});
|
||||
|
||||
it("downloads a cached blob and shows an error dialog on failure", async () => {
|
||||
const blob = new Blob(["downloaded"]);
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
|
||||
|
||||
const vm = createVm({
|
||||
mxEvent: createMessageEvent({
|
||||
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
|
||||
}),
|
||||
});
|
||||
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = blob;
|
||||
|
||||
await vm.onDownloadClick(null);
|
||||
await vm.onDownloadClick(null);
|
||||
|
||||
expect(mockDownload).toHaveBeenNthCalledWith(1, { blob, name: "Image" });
|
||||
expect(mockDownload).toHaveBeenNthCalledWith(2, { blob, name: "Image" });
|
||||
|
||||
mockDownload.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
await vm.onDownloadClick(null);
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(
|
||||
ErrorDialog,
|
||||
expect.objectContaining({
|
||||
title: "Download failed",
|
||||
description: expect.stringContaining("boom"),
|
||||
}),
|
||||
);
|
||||
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale download completion after setProps changes the event", async () => {
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
|
||||
const firstDownload = createPendingPromise<void>();
|
||||
const eventA = createMessageEvent({
|
||||
event_id: "$eventA",
|
||||
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
|
||||
});
|
||||
const eventB = createMessageEvent({
|
||||
event_id: "$eventB",
|
||||
content: { msgtype: MsgType.Image, body: "Image B", url: "mxc://example.org/b" },
|
||||
});
|
||||
|
||||
const vm = createVm({ mxEvent: eventA });
|
||||
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = new Blob(["a"]);
|
||||
mockDownload.mockReturnValueOnce(firstDownload.promise);
|
||||
|
||||
const firstDownloadCall = vm.onDownloadClick(null);
|
||||
|
||||
expect(vm.getSnapshot().isDownloadLoading).toBe(true);
|
||||
|
||||
vm.setProps({ mxEvent: eventB });
|
||||
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = new Blob(["b"]);
|
||||
|
||||
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
|
||||
|
||||
const secondDownload = vm.onDownloadClick(null);
|
||||
await secondDownload;
|
||||
|
||||
firstDownload.resolve();
|
||||
await firstDownloadCall;
|
||||
|
||||
expect(mockDownload).toHaveBeenCalledTimes(2);
|
||||
expect(mockDownload).toHaveBeenNthCalledWith(2, {
|
||||
blob: expect.any(Blob),
|
||||
name: "Image B",
|
||||
});
|
||||
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale download permission results after dispose", async () => {
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
|
||||
const permission = createPendingPromise<boolean>();
|
||||
const event = createMessageEvent({
|
||||
event_id: "$eventA",
|
||||
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
|
||||
});
|
||||
|
||||
getHintsForMessageSpy.mockReturnValue({
|
||||
allowDownloadingMedia: jest.fn().mockReturnValue(permission.promise),
|
||||
} as never);
|
||||
|
||||
const vm = createVm({ mxEvent: event });
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
|
||||
|
||||
vm.dispose();
|
||||
permission.resolve(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
|
||||
});
|
||||
|
||||
it("dispatches reply and thread actions and forwards callbacks", async () => {
|
||||
const onOptionsClick = jest.fn();
|
||||
const onReactionsClick = jest.fn();
|
||||
const onToggleThreadExpanded = jest.fn();
|
||||
const threadReply = createMessageEvent({
|
||||
sender: "@bob:example.org",
|
||||
event_id: "$reply",
|
||||
content: {
|
||||
"msgtype": MsgType.Text,
|
||||
"body": "Reply",
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Thread,
|
||||
event_id: rootEvent.getId(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(threadReply, "isThreadRoot", { value: false });
|
||||
jest.spyOn(threadReply, "getThread").mockReturnValue({ rootEvent } as never);
|
||||
|
||||
const vm = createVm({
|
||||
mxEvent: threadReply,
|
||||
isCard: true,
|
||||
onOptionsClick,
|
||||
onReactionsClick,
|
||||
onToggleThreadExpanded,
|
||||
});
|
||||
mocked(PinningUtils.isPinned).mockReturnValue(false);
|
||||
|
||||
vm.onReplyClick(null);
|
||||
vm.onReplyInThreadClick(null);
|
||||
vm.onEditClick(null);
|
||||
await vm.onPinClick(null);
|
||||
vm.onHideClick(null);
|
||||
vm.onOptionsClick(null);
|
||||
vm.onReactionsClick(null);
|
||||
vm.onToggleThreadExpanded(null);
|
||||
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenNthCalledWith(1, {
|
||||
action: "reply_to_event",
|
||||
event: threadReply,
|
||||
context: TimelineRenderingType.Room,
|
||||
});
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenNthCalledWith(2, {
|
||||
action: Action.ShowThread,
|
||||
rootEvent,
|
||||
initialEvent: threadReply,
|
||||
scroll_into_view: true,
|
||||
highlighted: true,
|
||||
push: true,
|
||||
});
|
||||
expect(editEvent).toHaveBeenCalledWith(client, threadReply, TimelineRenderingType.Room, undefined);
|
||||
expect(PinningUtils.pinOrUnpinEvent).toHaveBeenCalledWith(client, threadReply);
|
||||
expect(PosthogTrackers.trackPinUnpinMessage).toHaveBeenCalledWith(expect.any(String), "Timeline");
|
||||
expect(setMediaVisibility).toHaveBeenCalledWith(threadReply, false);
|
||||
expect(onOptionsClick).toHaveBeenCalledWith(null);
|
||||
expect(onReactionsClick).toHaveBeenCalledWith(null);
|
||||
expect(onToggleThreadExpanded).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
describe("business logic parity", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "hides reply and react for non-actionable events",
|
||||
actionable: false,
|
||||
props: {},
|
||||
expectedActions: [],
|
||||
unexpectedActions: [ActionBarAction.Reply, ActionBarAction.React],
|
||||
},
|
||||
{
|
||||
name: "hides reply when sending messages is disabled",
|
||||
actionable: true,
|
||||
props: { canSendMessages: false },
|
||||
expectedActions: [ActionBarAction.React],
|
||||
unexpectedActions: [ActionBarAction.Reply],
|
||||
},
|
||||
{
|
||||
name: "hides react when reactions are disabled",
|
||||
actionable: true,
|
||||
props: { canReact: false },
|
||||
expectedActions: [ActionBarAction.Reply],
|
||||
unexpectedActions: [ActionBarAction.React],
|
||||
},
|
||||
{
|
||||
name: "hides react in search results",
|
||||
actionable: true,
|
||||
props: { isSearch: true },
|
||||
expectedActions: [ActionBarAction.Reply],
|
||||
unexpectedActions: [ActionBarAction.React],
|
||||
},
|
||||
])("$name", ({ actionable, props, expectedActions, unexpectedActions }) => {
|
||||
mocked(isContentActionable).mockReturnValue(actionable);
|
||||
|
||||
const vm = createVm(props);
|
||||
|
||||
expectedActions.forEach((action) => expect(vm.getSnapshot().actions).toContain(action));
|
||||
unexpectedActions.forEach((action) => expect(vm.getSnapshot().actions).not.toContain(action));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "shows expand collapse only when quote state is provided and reply should display",
|
||||
quoteExpanded: true,
|
||||
displayReply: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "hides expand collapse when quote state is missing",
|
||||
quoteExpanded: undefined,
|
||||
displayReply: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "hides expand collapse when reply should not display",
|
||||
quoteExpanded: false,
|
||||
displayReply: false,
|
||||
expected: false,
|
||||
},
|
||||
])("$name", ({ quoteExpanded, displayReply, expected }) => {
|
||||
mocked(shouldDisplayReply).mockReturnValue(displayReply);
|
||||
|
||||
const vm = createVm({ isQuoteExpanded: quoteExpanded });
|
||||
|
||||
expect(vm.getSnapshot().actions.includes(ActionBarAction.Expand)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "allows reply in thread for normal room messages in room timeline",
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
content: { msgtype: MsgType.Text, body: "Hello" },
|
||||
relation: undefined,
|
||||
type: EventType.RoomMessage,
|
||||
expectedReplyInThread: true,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "blocks reply in thread in thread timeline",
|
||||
timelineRenderingType: TimelineRenderingType.Thread,
|
||||
content: { msgtype: MsgType.Text, body: "Hello" },
|
||||
relation: undefined,
|
||||
type: EventType.RoomMessage,
|
||||
expectedReplyInThread: false,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "blocks reply in thread for verification requests",
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
content: { msgtype: MsgType.KeyVerificationRequest, body: "verify" },
|
||||
relation: undefined,
|
||||
type: EventType.RoomMessage,
|
||||
expectedReplyInThread: false,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "blocks reply in thread for beacon info events",
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
content: {},
|
||||
relation: undefined,
|
||||
type: M_BEACON_INFO.name,
|
||||
expectedReplyInThread: false,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "marks non-thread relations as not thread reply allowed",
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
content: { msgtype: MsgType.Text, body: "Hello" },
|
||||
relation: { rel_type: RelationType.Annotation },
|
||||
type: EventType.RoomMessage,
|
||||
expectedReplyInThread: true,
|
||||
expectedAllowed: false,
|
||||
},
|
||||
])("$name", ({ timelineRenderingType, content, relation, type, expectedReplyInThread, expectedAllowed }) => {
|
||||
const mxEvent = new MatrixEvent({
|
||||
type,
|
||||
room_id: roomId,
|
||||
sender: userId,
|
||||
event_id: "$scenario",
|
||||
content,
|
||||
});
|
||||
jest.spyOn(mxEvent, "getRelation").mockReturnValue(relation as never);
|
||||
|
||||
const vm = createVm({ mxEvent, timelineRenderingType });
|
||||
|
||||
expect(vm.getSnapshot().actions.includes(ActionBarAction.ReplyInThread)).toBe(expectedReplyInThread);
|
||||
expect(vm.getSnapshot().isThreadReplyAllowed).toBe(expectedAllowed);
|
||||
});
|
||||
|
||||
it("shows thread action for deleted messages with a thread in the room timeline", () => {
|
||||
const mxEvent = createMessageEvent();
|
||||
mocked(isContentActionable).mockReturnValue(false);
|
||||
jest.spyOn(mxEvent, "getThread").mockReturnValue({ rootEvent } as never);
|
||||
|
||||
const vm = createVm({ mxEvent, timelineRenderingType: TimelineRenderingType.Room });
|
||||
|
||||
expect(vm.getSnapshot().actions).toContain(ActionBarAction.ReplyInThread);
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Reply);
|
||||
});
|
||||
|
||||
it("matches media visibility rules for hide and download actions", async () => {
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
|
||||
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(true);
|
||||
getHintsForMessageSpy.mockReturnValue({
|
||||
allowDownloadingMedia: jest.fn().mockResolvedValue(false),
|
||||
} as never);
|
||||
|
||||
const mxEvent = createMessageEvent({
|
||||
content: { msgtype: MsgType.Image, body: "Image", file: { url: "mxc://example.org/file" } },
|
||||
});
|
||||
const vm = createVm({ mxEvent });
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
isDownloadEncrypted: true,
|
||||
});
|
||||
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Hide);
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
|
||||
|
||||
await waitFor(() => expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download));
|
||||
});
|
||||
|
||||
it("recomputes parity-relevant flags and resets download state when the event changes", () => {
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
|
||||
|
||||
const vm = createVm({
|
||||
mxEvent: createMessageEvent({
|
||||
event_id: "$image",
|
||||
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
|
||||
}),
|
||||
});
|
||||
(vm as unknown as { downloadedBlob?: Blob; isDownloadLoading: boolean }).downloadedBlob = new Blob(["x"]);
|
||||
(vm as unknown as { downloadedBlob?: Blob; isDownloadLoading: boolean }).isDownloadLoading = true;
|
||||
|
||||
mocked(isContentActionable).mockReturnValue(false);
|
||||
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(false);
|
||||
|
||||
vm.setProps({
|
||||
mxEvent: createMessageEvent({
|
||||
event_id: "$text",
|
||||
content: { msgtype: MsgType.Text, body: "Text" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Hide);
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Reply);
|
||||
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.React);
|
||||
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user