diff --git a/apps/web/.stylelintrc.cjs b/apps/web/.stylelintrc.cjs index 57c39e8c05..2ccb2abf75 100644 --- a/apps/web/.stylelintrc.cjs +++ b/apps/web/.stylelintrc.cjs @@ -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" }, diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index ad1eef3644..b4f49e767f 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -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"; diff --git a/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss b/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss index 5d06545ae8..d14bb3ca60 100644 --- a/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss +++ b/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.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; } } diff --git a/apps/web/res/css/views/messages/_MessageActionBar.pcss b/apps/web/res/css/views/messages/_MessageActionBar.pcss index 31a83d3feb..d078632ce2 100644 --- a/apps/web/res/css/views/messages/_MessageActionBar.pcss +++ b/apps/web/res/css/views/messages/_MessageActionBar.pcss @@ -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 */ - } - } - } - } } diff --git a/apps/web/res/css/views/messages/_ThreadActionBar.pcss b/apps/web/res/css/views/messages/_ThreadActionBar.pcss new file mode 100644 index 0000000000..e6f71fc024 --- /dev/null +++ b/apps/web/res/css/views/messages/_ThreadActionBar.pcss @@ -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; + } + } +} diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index 59bb2d23d0..5bea427961 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -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; } diff --git a/apps/web/src/components/views/messages/DownloadActionButton.tsx b/apps/web/src/components/views/messages/DownloadActionButton.tsx deleted file mode 100644 index af6ad8cc1d..0000000000 --- a/apps/web/src/components/views/messages/DownloadActionButton.tsx +++ /dev/null @@ -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 ? : undefined; - const classes = classNames({ - mx_MessageActionBar_iconButton: true, - mx_MessageActionBar_downloadButton: true, - mx_MessageActionBar_downloadSpinnerButton: !!spinner, - }); - - return ( - - - {spinner} - - ); -} diff --git a/apps/web/src/components/views/messages/EditHistoryMessage.tsx b/apps/web/src/components/views/messages/EditHistoryMessage.tsx index e6af74f2b1..9d533a792a 100644 --- a/apps/web/src/components/views/messages/EditHistoryMessage.tsx +++ b/apps/web/src/components/views/messages/EditHistoryMessage.tsx @@ -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(); private EventContentBodyViewModel: EventContentBodyViewModel; + private editHistoryActionBarViewModel: EditHistoryActionBarViewModel; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -72,6 +72,13 @@ export default class EditHistoryMessage extends React.PureComponent { @@ -116,34 +130,20 @@ export default class EditHistoryMessage extends React.PureComponent{_t("action|remove")}; - } + 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 = ( - {_t("action|view_source")} - ); - } - - if (!redactButton && !viewSourceButton) { - // Hide the empty MessageActionBar - return null; - } else { - // disabled remove button when not allowed - return ( -
- {redactButton} - {viewSourceButton} -
- ); - } + return ( + + ); } public render(): React.ReactNode { diff --git a/apps/web/src/components/views/messages/HideActionButton.tsx b/apps/web/src/components/views/messages/HideActionButton.tsx deleted file mode 100644 index ba0c8568f1..0000000000 --- a/apps/web/src/components/views/messages/HideActionButton.tsx +++ /dev/null @@ -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 = ({ mxEvent }) => { - const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent); - - if (!mediaIsVisible) { - return; - } - - return ( - setVisible(false)} - placement="left" - > - - - ); -}; diff --git a/apps/web/src/components/views/messages/MessageActionBar.tsx b/apps/web/src/components/views/messages/MessageActionBar.tsx deleted file mode 100644 index 5462ca4213..0000000000 --- a/apps/web/src/components/views/messages/MessageActionBar.tsx +++ /dev/null @@ -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 = ({ - 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 = ( - - ); - } - - return ( - - - - - {contextMenu} - - ); -}; - -interface IReactButtonProps { - mxEvent: MatrixEvent; - reactions?: Relations | null | undefined; - onFocusChange: (menuDisplayed: boolean) => void; -} - -const ReactButton: React.FC = ({ 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 = ( - - - - ); - } - - 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 ( - - - - - - {contextMenu} - - ); -}; - -interface IReplyInThreadButton { - mxEvent: MatrixEvent; -} - -const ReplyInThreadButton: React.FC = ({ 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({ - action: Action.ShowThread, - rootEvent: thread.rootEvent, - initialEvent: mxEvent, - scroll_into_view: true, - highlighted: true, - push: context.isCard, - }); - } else { - defaultDispatcher.dispatch({ - action: Action.ShowThread, - rootEvent: mxEvent, - push: context.isCard, - }); - } - }; - - const title = !hasARelation ? _t("action|reply_in_thread") : _t("threads|error_start_thread_existing_relation"); - - return ( - - - - ); -}; - -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 { - public static contextType = RoomContext; - declare public context: React.ContextType; - - 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 => { - // 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( - - - , - ); - } - - 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( - this.onPinClick(e, isPinned)} - onContextMenu={(e: ButtonEvent) => this.onPinClick(e, isPinned)} - key="pin" - placement="top" - > - {isPinned ? : } - , - ); - } - - const cancelSendingButton = ( - - - - ); - - const threadTooltipButton = ; - - // 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, - - - , - ); - - // 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, - - - , - ); - } - // 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, - , - ); - } - - // 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, - this.props.getTile()?.getMediaHelper?.()} - key="download" - />, - ); - } - if (MediaEventHelper.canHide(this.props.mxEvent)) { - toolbarOpts.splice(0, 0, ); - } - } 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( - - {this.props.isQuoteExpanded ? : } - , - ); - } - - // The menu button should be last, so dump it there. - toolbarOpts.push( - , - ); - } - - // aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive. - return ( - - {toolbarOpts} - - ); - } -} diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index aa0f9ac0e4..922f0171cf 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -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 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 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 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 } } - private viewInRoom = (evt: ButtonEvent): void => { - evt.preventDefault(); - evt.stopPropagation(); + private readonly onViewInRoomClick = (_anchor: HTMLElement | null): void => { dis.dispatch({ action: Action.ViewRoom, event_id: this.props.mxEvent.getId(), @@ -573,16 +575,14 @@ export class UnwrappedEventTile extends React.Component }); }; - private copyLinkToThread = async (evt: ButtonEvent): Promise => { - evt.preventDefault(); - evt.stopPropagation(); + private readonly onCopyLinkToThreadClick = async (_anchor: HTMLElement | null): Promise => { 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 /** 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 return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight); } - private onSenderProfileClick = (): void => { + private readonly onSenderProfileClick = (): void => { dis.dispatch({ action: Action.ComposerInsert, userId: this.props.mxEvent.getSender()!, @@ -740,7 +740,7 @@ export class UnwrappedEventTile extends React.Component }); }; - 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 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): 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): 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 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 }); }; - 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 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 } } - 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 ? ( - "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, }, [
@@ -1348,15 +1374,15 @@ export class UnwrappedEventTile extends React.Component "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({ @@ -1411,9 +1437,9 @@ export class UnwrappedEventTile extends React.Component {this.renderThreadPanelSummary()}
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( - )} @@ -1481,8 +1507,8 @@ export class UnwrappedEventTile extends React.Component "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 ); } + +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; +} + +function ThreadListActionBarWrapper({ + onViewInRoomClick, + onCopyLinkClick, +}: Readonly): JSX.Element { + const vm = useCreateAutoDisposedViewModel( + () => + new ThreadListActionBarViewModel({ + onViewInRoomClick, + onCopyLinkClick, + }), + ); + + useEffect(() => { + vm.setProps({ + onViewInRoomClick, + onCopyLinkClick, + }); + }, [vm, onViewInRoomClick, onCopyLinkClick]); + + return ; +} + +function ActionBarWrapper({ + mxEvent, + reactions, + permalinkCreator, + getTile, + getReplyChain, + onFocusChange, + isQuoteExpanded, + toggleThreadExpanded, + getRelationsForEvent, +}: Readonly): JSX.Element { + const roomContext = useContext(RoomContext); + const { isCard } = useContext(CardContext); + const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); + const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(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 ( + <> + + {optionsMenuAnchorRect ? ( + + ) : null} + {reactionsMenuAnchorRect ? ( + + + + ) : null} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx deleted file mode 100644 index bd29b53fb7..0000000000 --- a/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx +++ /dev/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 ( - - - - - - - - - ); -} diff --git a/apps/web/src/hooks/useMediaVisible.ts b/apps/web/src/hooks/useMediaVisible.ts index de0b0fbf6d..58540be8fb 100644 --- a/apps/web/src/hooks/useMediaVisible.ts +++ b/apps/web/src/hooks/useMediaVisible.ts @@ -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, + ]; } diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 283e58fcb6..187908a642 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -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": { diff --git a/apps/web/src/utils/media/mediaVisibility.ts b/apps/web/src/utils/media/mediaVisibility.ts new file mode 100644 index 0000000000..daebd52715 --- /dev/null +++ b/apps/web/src/utils/media/mediaVisibility.ts @@ -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, + 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 { + const eventId = mxEvent.getId(); + if (!eventId) return; + + const eventVisibility = SettingsStore.getValue("showMediaEventIds"); + + await SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, { + ...eventVisibility, + [eventId]: visible, + }); +} diff --git a/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts b/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts new file mode 100644 index 0000000000..b0e2848c80 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts @@ -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 + 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): 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); + }; +} diff --git a/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts b/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts new file mode 100644 index 0000000000..ab6dacde49 --- /dev/null +++ b/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts @@ -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 + 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, + ): 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, + 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 { + 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): 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 => { + 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 => { + 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({ + action: Action.ShowThread, + rootEvent: thread.rootEvent, + initialEvent: mxEvent, + scroll_into_view: true, + highlighted: true, + push: isCard, + }); + return; + } + + defaultDispatcher.dispatch({ + action: Action.ShowThread, + rootEvent: mxEvent, + push: isCard, + }); + }; +} diff --git a/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts b/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts new file mode 100644 index 0000000000..60cbaf3c27 --- /dev/null +++ b/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts @@ -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 + 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): 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); + }; +} diff --git a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx index a1b43be232..77bbdd8d47 100644 --- a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx @@ -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 })); diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap index 048caf3d52..1ced7286ed 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap @@ -86,15 +86,23 @@ exports[` should match the snapshot 1`] = ` @@ -224,15 +232,23 @@ exports[` should support events with 1`] = ` @@ -278,15 +294,23 @@ exports[` should support events with 1`] = ` @@ -314,15 +338,23 @@ exports[` should support events with 1`] = ` @@ -332,7 +364,7 @@ exports[` should support events with 1`] = `
({ - 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( 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( 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( 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( mediaEventHelper} />); - - const button = screen.getByRole("button"); - await user.hover(button); - - await user.click(button); - - await waitFor(() => { - expect(screen.getByRole("tooltip")).toHaveTextContent("Decrypting"); - }); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/HideActionButton-test.tsx b/apps/web/test/unit-tests/components/views/messages/HideActionButton-test.tsx deleted file mode 100644 index fbb8ab7dd0..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/HideActionButton-test.tsx +++ /dev/null @@ -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; - 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(, withClientContextRenderOptions(cli)); - expect(screen.getByRole("button")).toBeVisible(); - }); - it("should show button when event is visible by mediaPreviewConfig setting", async () => { - mockSetting(MediaPreviewValue.On, {}); - render(, 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(, withClientContextRenderOptions(cli)); - expect(screen.queryByRole("button")).toBeNull(); - }); - it("should hide button when event is hidden by showImages setting", async () => { - mockSetting(MediaPreviewValue.Off, {}); - render(, withClientContextRenderOptions(cli)); - expect(screen.queryByRole("button")).toBeNull(); - }); - it("should store event as hidden when clicked", async () => { - const spy = jest.spyOn(SettingsStore, "setValue"); - render(, 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(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/MessageActionBar-test.tsx b/apps/web/test/unit-tests/components/views/messages/MessageActionBar-test.tsx deleted file mode 100644 index e44e25d78d..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/MessageActionBar-test.tsx +++ /dev/null @@ -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("", () => { - 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 = {}; - 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 = {}) => - render( - - - , - ); - - 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(); - }); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileThreadToolbar-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileThreadToolbar-test.tsx deleted file mode 100644 index 38785d5b45..0000000000 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileThreadToolbar-test.tsx +++ /dev/null @@ -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> = {}): RenderResult { - return render(); - } - - 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); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap deleted file mode 100644 index 7c1a4ed772..0000000000 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap +++ /dev/null @@ -1,49 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`EventTileThreadToolbar renders 1`] = ` - - - -`; diff --git a/apps/web/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap index 4d46eadd10..39b8171905 100644 --- a/apps/web/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap @@ -111,53 +111,6 @@ exports[` should render 1`] = ` Hey you. You're the best!
- @@ -169,7 +122,7 @@ exports[` should render 1`] = `