Files
ThreadNet-Web/apps/web/src/components/views/elements/ImageView.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

662 lines
25 KiB
TypeScript
Raw Normal View History

/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
2021-04-09 08:01:14 +02:00
Copyright 2020, 2021 Šimon Brandner <simon.bra.ag@gmail.com>
2024-09-09 14:57:16 +01:00
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
*/
2025-07-17 11:53:11 +02:00
import React, { type JSX, createRef, type CSSProperties, useEffect } from "react";
2021-10-22 17:23:32 -05:00
import FocusLock from "react-focus-lock";
2025-07-17 11:53:11 +02:00
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
CloseIcon,
DownloadIcon,
OverflowHorizontalIcon,
RotateLeftIcon,
RotateRightIcon,
ZoomInIcon,
ZoomOutIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useCreateAutoDisposedViewModel, MessageTimestampView } from "@element-hq/web-shared-components";
2021-10-22 17:23:32 -05:00
2018-04-13 00:43:44 +01:00
import { _t } from "../../../languageHandler";
2021-02-24 14:43:33 +01:00
import MemberAvatar from "../avatars/MemberAvatar";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
2021-02-24 19:17:33 +01:00
import MessageContextMenu from "../context_menus/MessageContextMenu";
2021-07-06 20:59:12 +02:00
import { aboveLeftOf } from "../../structures/ContextMenu";
2021-02-24 20:07:41 +01:00
import SettingsStore from "../../../settings/SettingsStore";
2021-02-24 20:14:12 +01:00
import dis from "../../../dispatcher/dispatcher";
2021-11-25 17:49:43 -03:00
import { Action } from "../../../dispatcher/actions";
2025-02-05 13:25:06 +00:00
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { normalizeWheelEvent } from "../../../utils/Mouse";
2021-07-22 21:26:15 +02:00
import UIStore from "../../../stores/UIStore";
2025-02-05 13:25:06 +00:00
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { presentableTextForFile } from "../../../utils/FileUtils";
import AccessibleButton from "./AccessibleButton";
2025-07-17 11:53:11 +02:00
import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
import {
MessageTimestampViewModel,
type MessageTimestampViewModelProps,
} from "../../../viewmodels/message-body/MessageTimestampViewModel.ts";
2021-04-24 08:03:39 +02:00
// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
2021-03-15 19:28:21 +01:00
// This is used for the buttons
2021-04-24 10:35:25 +02:00
const ZOOM_STEP = 0.1;
2021-03-15 19:28:21 +01:00
// This is used for mouse wheel events
2021-04-24 10:35:25 +02:00
const ZOOM_COEFFICIENT = 0.0025;
2021-04-03 16:19:22 +02:00
// If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10;
2021-02-25 11:19:50 +01:00
2021-09-14 18:06:01 +02:00
// Height of mx_ImageView_panel
2021-09-21 17:34:50 +02:00
const getPanelHeight = (): number => {
const value = getComputedStyle(document.documentElement).getPropertyValue("--image-view-panel-height");
// Return the value as a number without the unit
return parseInt(value.slice(0, value.length - 2));
};
2021-07-22 21:52:36 +02:00
interface IProps {
2021-07-01 23:23:03 +01:00
src: string; // the source of the image being displayed
name?: string; // the main title ('name') for the image
link?: string; // the link (if any) applied to the name of the image
width?: number; // width of the image src in pixels
height?: number; // height of the image src in pixels
fileSize?: number; // size of the image src in bytes
// the event (if any) that the Image is displaying. Used for event-specific stuff like
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
// properties above, which let us use lightboxes to display images which aren't associated
// with events.
2021-07-22 21:26:15 +02:00
mxEvent?: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
thumbnailInfo?: {
positionX: number;
positionY: number;
width: number;
height: number;
};
onFinished(): void;
}
interface IState {
2021-07-01 23:23:03 +01:00
zoom: number;
minZoom: number;
maxZoom: number;
rotation: number;
translationX: number;
translationY: number;
moving: boolean;
contextMenuDisplayed: boolean;
}
export default class ImageView extends React.Component<IProps, IState> {
2023-02-13 11:39:16 +00:00
public constructor(props: IProps) {
2019-03-29 23:31:15 -07:00
super(props);
2021-07-22 21:26:15 +02:00
2021-07-23 13:52:29 +02:00
const { thumbnailInfo } = this.props;
2021-07-22 21:26:15 +02:00
let translationX = 0;
let translationY = 0;
if (thumbnailInfo) {
translationX = thumbnailInfo.positionX + thumbnailInfo.width / 2 - UIStore.instance.windowWidth / 2;
translationY =
thumbnailInfo.positionY +
thumbnailInfo.height / 2 -
UIStore.instance.windowHeight / 2 -
getPanelHeight() / 2;
}
2020-12-19 10:20:15 +01:00
this.state = {
2021-07-23 13:52:29 +02:00
zoom: 0, // We default to 0 and override this in imageLoaded once we have naturalSize
2021-04-24 08:32:28 +02:00
minZoom: MAX_SCALE,
2021-04-24 09:24:25 +02:00
maxZoom: MAX_SCALE,
2020-12-20 17:40:16 +01:00
rotation: 0,
translationX,
translationY,
2020-12-20 17:40:16 +01:00
moving: false,
2021-02-25 11:16:40 +01:00
contextMenuDisplayed: false,
2020-12-19 10:20:15 +01:00
};
2019-03-29 23:31:15 -07:00
}
// XXX: Refs to functional components
2021-04-09 08:02:38 +02:00
private contextMenuButton = createRef<any>();
private focusLock = createRef<any>();
2021-04-26 13:11:41 +02:00
private imageWrapper = createRef<HTMLDivElement>();
2021-04-26 13:30:14 +02:00
private image = createRef<HTMLImageElement>();
2025-07-17 11:53:11 +02:00
private downloadFunction?: () => Promise<void>;
2021-04-09 08:02:38 +02:00
private initX = 0;
private initY = 0;
private previousX = 0;
private previousY = 0;
2020-12-20 17:40:16 +01:00
2021-07-23 13:52:29 +02:00
private animatingLoading = false;
private imageIsLoaded = false;
2021-07-23 08:00:51 +02:00
public componentDidMount(): void {
2021-02-25 07:51:38 +01:00
// We have to use addEventListener() because the listener
// needs to be passive in order to work with Chromium
this.focusLock.current.addEventListener("wheel", this.onWheel, { passive: false });
2021-04-26 13:49:29 +02:00
// We want to recalculate zoom whenever the window's size changes
2021-06-07 15:39:11 +02:00
window.addEventListener("resize", this.recalculateZoom);
2021-04-26 13:30:14 +02:00
// After the image loads for the first time we want to calculate the zoom
this.image.current?.addEventListener("load", this.imageLoaded);
}
public componentWillUnmount(): void {
this.focusLock.current.removeEventListener("wheel", this.onWheel);
2021-06-07 15:39:11 +02:00
window.removeEventListener("resize", this.recalculateZoom);
this.image.current?.removeEventListener("load", this.imageLoaded);
}
private imageLoaded = (): void => {
if (!this.image.current) return;
2021-07-23 13:52:29 +02:00
// First, we calculate the zoom, so that the image has the same size as
// the thumbnail
const { thumbnailInfo } = this.props;
if (thumbnailInfo?.width) {
2021-09-14 18:15:27 +02:00
this.setState({ zoom: thumbnailInfo.width / this.image.current.naturalWidth });
2021-07-23 13:52:29 +02:00
}
// Once the zoom is set, we the image is considered loaded and we can
// start animating it into the center of the screen
this.imageIsLoaded = true;
this.animatingLoading = true;
2021-07-22 21:26:15 +02:00
this.setZoomAndRotation();
2021-09-14 18:15:27 +02:00
this.setState({
2021-07-22 21:26:15 +02:00
translationX: 0,
translationY: 0,
});
2021-07-23 13:52:29 +02:00
// Once the position is set, there is no need to animate anymore
this.animatingLoading = false;
2021-07-22 21:26:15 +02:00
};
private recalculateZoom = (): void => {
2021-06-07 15:39:11 +02:00
this.setZoomAndRotation();
2021-06-29 13:11:58 +01:00
};
2021-06-07 15:39:11 +02:00
private setZoomAndRotation = (inputRotation?: number): void => {
2021-04-26 13:30:14 +02:00
const image = this.image.current;
2021-04-26 13:11:41 +02:00
const imageWrapper = this.imageWrapper.current;
if (!image || !imageWrapper) return;
2021-04-24 09:41:46 +02:00
2021-07-03 15:18:47 -04:00
const rotation = inputRotation ?? this.state.rotation;
2021-06-07 15:39:11 +02:00
2021-06-07 15:46:03 +02:00
const imageIsNotFlipped = rotation % 180 === 0;
// If the image is rotated take it into account
2021-06-07 15:46:03 +02:00
const width = imageIsNotFlipped ? image.naturalWidth : image.naturalHeight;
const height = imageIsNotFlipped ? image.naturalHeight : image.naturalWidth;
const zoomX = imageWrapper.clientWidth / width;
const zoomY = imageWrapper.clientHeight / height;
2021-04-26 13:47:06 +02:00
// If the image is smaller in both dimensions set its the zoom to 1 to
// display it in its original size
if (zoomX >= 1 && zoomY >= 1) {
this.setState({
zoom: 1,
minZoom: 1,
maxZoom: 1,
2021-06-07 15:39:11 +02:00
rotation: rotation,
2021-04-26 13:47:06 +02:00
});
return;
}
2021-04-24 09:41:46 +02:00
// We set minZoom to the min of the zoomX and zoomY to avoid overflow in
// any direction. We also multiply by MAX_SCALE to get a gap around the
// image by default
2021-04-24 08:32:28 +02:00
const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE;
2021-04-24 08:03:39 +02:00
2021-06-07 15:26:54 +02:00
// If zoom is smaller than minZoom don't go below that value
2021-06-06 08:56:12 +02:00
const zoom = this.state.zoom <= this.state.minZoom ? minZoom : this.state.zoom;
2021-04-24 08:32:28 +02:00
this.setState({
minZoom: minZoom,
2021-04-26 13:47:06 +02:00
maxZoom: 1,
2021-06-07 15:39:11 +02:00
rotation: rotation,
2021-06-06 08:56:12 +02:00
zoom: zoom,
2021-04-24 08:32:28 +02:00
});
2021-06-29 13:11:58 +01:00
};
2021-04-24 08:03:39 +02:00
private zoomDelta(delta: number, anchorX?: number, anchorY?: number): void {
2021-07-22 09:48:56 -04:00
this.zoom(this.state.zoom + delta, anchorX, anchorY);
2021-07-21 02:13:30 -04:00
}
private zoom(zoomLevel: number, anchorX?: number, anchorY?: number): void {
2021-07-21 02:13:30 -04:00
const oldZoom = this.state.zoom;
const maxZoom = this.state.maxZoom === this.state.minZoom ? 2 * this.state.maxZoom : this.state.maxZoom;
const newZoom = Math.min(zoomLevel, maxZoom);
2021-04-24 08:32:28 +02:00
if (newZoom <= this.state.minZoom) {
2021-07-21 02:13:30 -04:00
// Zoom out fully
2020-12-19 10:20:15 +01:00
this.setState({
2021-04-24 08:32:28 +02:00
zoom: this.state.minZoom,
2020-12-20 20:09:01 +01:00
translationX: 0,
translationY: 0,
2020-12-19 10:20:15 +01:00
});
} else if (typeof anchorX !== "number" || typeof anchorY !== "number") {
2021-07-21 02:13:30 -04:00
// Zoom relative to the center of the view
this.setState({
zoom: newZoom,
translationX: (this.state.translationX * newZoom) / oldZoom,
translationY: (this.state.translationY * newZoom) / oldZoom,
});
} else if (this.image.current) {
2021-07-21 02:13:30 -04:00
// Zoom relative to the given point on the image.
// First we need to figure out the offset of the anchor point
// relative to the center of the image, accounting for rotation.
let offsetX: number;
let offsetY: number;
2021-07-22 09:56:26 -04:00
// The modulo operator can return negative values for some
// rotations, so we have to do some extra work to normalize it
const rotation = (((this.state.rotation % 360) + 360) % 360) as 0 | 90 | 180 | 270;
switch (rotation) {
2021-07-21 02:13:30 -04:00
case 0:
2021-07-22 09:48:56 -04:00
offsetX = this.image.current.clientWidth / 2 - anchorX;
offsetY = this.image.current.clientHeight / 2 - anchorY;
2021-07-21 02:13:30 -04:00
break;
case 90:
2021-07-22 09:48:56 -04:00
offsetX = anchorY - this.image.current.clientHeight / 2;
offsetY = this.image.current.clientWidth / 2 - anchorX;
2021-07-21 02:13:30 -04:00
break;
case 180:
2021-07-22 09:48:56 -04:00
offsetX = anchorX - this.image.current.clientWidth / 2;
offsetY = anchorY - this.image.current.clientHeight / 2;
2021-07-21 02:13:30 -04:00
break;
case 270:
2021-07-22 09:48:56 -04:00
offsetX = this.image.current.clientHeight / 2 - anchorY;
offsetY = anchorX - this.image.current.clientWidth / 2;
2021-07-21 02:13:30 -04:00
}
2020-12-20 20:28:19 +01:00
2021-07-21 02:13:30 -04:00
// Apply the zoom and offset
this.setState({
zoom: newZoom,
translationX: this.state.translationX + (newZoom - oldZoom) * offsetX,
translationY: this.state.translationY + (newZoom - oldZoom) * offsetY,
});
}
2021-04-24 10:36:53 +02:00
}
private onWheel = (ev: WheelEvent): void => {
2021-07-21 02:13:30 -04:00
if (ev.target === this.image.current) {
ev.stopPropagation();
ev.preventDefault();
const { deltaY } = normalizeWheelEvent(ev);
2021-07-21 02:13:30 -04:00
// Zoom in on the point on the image targeted by the cursor
this.zoomDelta(-deltaY * ZOOM_COEFFICIENT, ev.offsetX, ev.offsetY);
}
2021-04-24 10:36:53 +02:00
};
private onZoomInClick = (): void => {
2021-07-21 02:13:30 -04:00
this.zoomDelta(ZOOM_STEP);
2021-04-24 10:36:53 +02:00
};
private onZoomOutClick = (): void => {
2021-07-21 02:13:30 -04:00
this.zoomDelta(-ZOOM_STEP);
2021-04-24 10:36:53 +02:00
};
private onKeyDown = (ev: KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Escape:
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
break;
2025-07-17 11:53:11 +02:00
case KeyBindingAction.Save:
ev.preventDefault();
ev.stopPropagation();
if (this.downloadFunction) {
this.downloadFunction();
}
break;
2021-04-24 10:36:53 +02:00
}
};
2020-12-19 10:20:15 +01:00
private onRotateCounterClockwiseClick = (): void => {
2020-12-20 17:40:16 +01:00
const cur = this.state.rotation;
2021-06-07 15:39:11 +02:00
this.setZoomAndRotation(cur - 90);
2019-03-29 23:31:15 -07:00
};
private onRotateClockwiseClick = (): void => {
2020-12-20 17:40:16 +01:00
const cur = this.state.rotation;
2021-06-07 15:39:11 +02:00
this.setZoomAndRotation(cur + 90);
2019-03-29 23:31:15 -07:00
};
private onOpenContextMenu = (): void => {
2021-02-24 19:17:33 +01:00
this.setState({
2021-02-25 11:16:40 +01:00
contextMenuDisplayed: true,
2021-02-24 19:17:33 +01:00
});
};
2021-02-24 19:17:33 +01:00
private onCloseContextMenu = (): void => {
2021-02-24 19:17:33 +01:00
this.setState({
2021-02-25 11:16:40 +01:00
contextMenuDisplayed: false,
2021-02-24 19:17:33 +01:00
});
};
2021-02-24 19:17:33 +01:00
2025-07-17 11:53:11 +02:00
private onDownloadFunctionReady = (download: () => Promise<void>): void => {
this.downloadFunction = download;
};
private onPermalinkClicked = (ev: React.MouseEvent): void => {
2021-02-24 20:14:12 +01:00
// 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.
2021-02-25 11:23:14 +01:00
ev.preventDefault();
dis.dispatch<ViewRoomPayload>({
2021-11-25 17:49:43 -03:00
action: Action.ViewRoom,
event_id: this.props.mxEvent?.getId(),
2021-02-24 20:14:12 +01:00
highlighted: true,
room_id: this.props.mxEvent?.getRoomId(),
metricsTrigger: undefined, // room doesn't change
2021-02-24 20:14:12 +01:00
});
2021-02-25 08:13:27 +01:00
this.props.onFinished();
2021-02-24 20:14:12 +01:00
};
private onStartMoving = (ev: React.MouseEvent): void => {
2020-12-20 17:40:16 +01:00
ev.stopPropagation();
ev.preventDefault();
// Don't do anything if we pressed any
// other button than the left one
if (ev.button !== 0) return;
// Zoom in if we are completely zoomed out and increase the zoom factor for images
// smaller than the viewport size
2021-04-24 08:32:28 +02:00
if (this.state.zoom === this.state.minZoom) {
this.zoom(
this.state.maxZoom === this.state.minZoom ? 2 * this.state.maxZoom : this.state.maxZoom,
ev.nativeEvent.offsetX,
ev.nativeEvent.offsetY,
);
2021-04-02 10:37:42 +02:00
return;
}
this.setState({ moving: true });
2021-04-02 10:01:41 +02:00
this.previousX = this.state.translationX;
this.previousY = this.state.translationY;
2021-06-06 08:14:43 +02:00
this.initX = ev.pageX - this.state.translationX;
this.initY = ev.pageY - this.state.translationY;
};
2020-12-20 17:40:16 +01:00
private onMoving = (ev: React.MouseEvent): void => {
2020-12-20 17:40:16 +01:00
ev.stopPropagation();
ev.preventDefault();
2021-04-02 10:22:10 +02:00
if (!this.state.moving) return;
2020-12-20 17:40:16 +01:00
this.setState({
2021-06-06 08:14:43 +02:00
translationX: ev.pageX - this.initX,
translationY: ev.pageY - this.initY,
2020-12-20 17:40:16 +01:00
});
};
2020-12-20 17:40:16 +01:00
private onEndMoving = (): void => {
2021-04-02 10:37:42 +02:00
// Zoom out if we haven't moved much
2021-04-02 10:01:41 +02:00
if (
this.state.moving &&
2021-04-03 16:19:22 +02:00
Math.abs(this.state.translationX - this.previousX) < ZOOM_DISTANCE &&
Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE
2021-04-02 10:01:41 +02:00
) {
2021-07-21 02:13:30 -04:00
this.zoom(this.state.minZoom);
2021-06-06 08:09:41 +02:00
this.initX = 0;
this.initY = 0;
2021-04-02 10:01:41 +02:00
}
this.setState({ moving: false });
};
2020-12-20 17:40:16 +01:00
private renderContextMenu(): JSX.Element {
let contextMenu: JSX.Element | undefined;
if (this.state.contextMenuDisplayed && this.props.mxEvent) {
2021-02-24 19:17:33 +01:00
contextMenu = (
2021-07-06 20:59:12 +02:00
<MessageContextMenu
2021-02-24 19:17:33 +01:00
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
2021-07-06 20:59:12 +02:00
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator}
2021-02-24 19:17:33 +01:00
onFinished={this.onCloseContextMenu}
2021-07-06 20:59:12 +02:00
onCloseDialog={this.props.onFinished}
/>
2021-02-24 19:17:33 +01:00
);
}
return <React.Fragment>{contextMenu}</React.Fragment>;
}
public render(): React.ReactNode {
2020-12-20 17:40:16 +01:00
const showEventMeta = !!this.props.mxEvent;
2021-09-21 17:59:13 +02:00
let transitionClassName;
if (this.animatingLoading) transitionClassName = "mx_ImageView_image_animatingLoading";
else if (this.state.moving || !this.imageIsLoaded) transitionClassName = "";
else transitionClassName = "mx_ImageView_image_animating";
2021-07-23 08:00:51 +02:00
2020-12-20 17:40:16 +01:00
const rotationDegrees = this.state.rotation + "deg";
2021-04-24 08:35:45 +02:00
const zoom = this.state.zoom;
2020-12-20 17:40:16 +01:00
const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px";
2021-02-25 07:51:38 +01:00
// The order of the values is important!
// First, we translate and only then we rotate, otherwise
// we would apply the translation to an already rotated
// image causing it translate in the wrong direction.
const style: CSSProperties = {
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})
2021-04-24 08:35:45 +02:00
scale(${zoom})
rotate(${rotationDegrees})`,
2020-12-20 17:40:16 +01:00
};
2019-03-29 23:31:15 -07:00
if (this.state.moving) style.cursor = "grabbing";
else if (this.state.zoom === this.state.minZoom) style.cursor = "zoom-in";
else style.cursor = "zoom-out";
let info: JSX.Element | undefined;
2021-02-24 20:04:25 +01:00
if (showEventMeta) {
const mxEvent = this.props.mxEvent!;
2021-02-24 20:07:41 +01:00
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
2021-02-24 20:14:12 +01:00
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(mxEvent.getId()!);
2021-02-24 20:14:12 +01:00
}
2021-02-24 20:04:25 +01:00
const senderName = mxEvent.sender?.name ?? mxEvent.getSender();
2021-04-02 08:31:42 +02:00
const sender = <div className="mx_ImageView_info_sender">{senderName}</div>;
2021-02-24 20:04:25 +01:00
const messageTimestamp = (
<MessageTimestampWrapper
2021-02-24 20:14:12 +01:00
href={permalink}
onClick={this.onPermalinkClicked}
showFullDate={true}
showTwelveHour={showTwelveHour}
ts={mxEvent.getTs()}
showSeconds={false}
inhibitTooltip
/>
2021-02-24 20:04:25 +01:00
);
const avatar = (
<MemberAvatar
member={mxEvent.sender}
2021-09-01 16:50:13 +02:00
fallbackUserId={mxEvent.getSender()}
size="32px"
2021-02-24 20:04:25 +01:00
viewUserOnClick={true}
2023-09-21 12:42:23 +01:00
className="mx_Dialog_nonDialogButton"
2021-02-24 20:04:25 +01:00
/>
);
2021-02-24 18:24:44 +01:00
info = (
<div className="mx_ImageView_info_wrapper">
2021-06-07 16:12:06 +02:00
{avatar}
2021-02-24 18:24:44 +01:00
<div className="mx_ImageView_info">
2021-06-07 16:12:06 +02:00
{sender}
{messageTimestamp}
2021-02-24 18:24:44 +01:00
</div>
</div>
);
} else {
// If there is no event - we're viewing an avatar, we set
// an empty div here, since the panel uses space-between
// and we want the same placement of elements
2021-07-23 10:23:45 +01:00
info = <div />;
2021-02-24 18:24:44 +01:00
}
let contextMenuButton: JSX.Element | undefined;
if (this.props.mxEvent) {
contextMenuButton = (
<ContextMenuTooltipButton
className="mx_ImageView_button mx_ImageView_button_more"
title={_t("common|options")}
onClick={this.onOpenContextMenu}
ref={this.contextMenuButton}
isExpanded={this.state.contextMenuDisplayed}
>
<OverflowHorizontalIcon />
</ContextMenuTooltipButton>
);
}
let title: JSX.Element | undefined;
if (this.props.mxEvent?.getContent()) {
title = (
<div className="mx_ImageView_title">
{presentableTextForFile(this.props.mxEvent?.getContent(), _t("common|image"), true)}
</div>
);
}
return (
<FocusLock
returnFocus={true}
lockProps={{
"onKeyDown": this.onKeyDown,
"role": "dialog",
"aria-label": _t("lightbox|title"),
}}
className="mx_ImageView"
ref={this.focusLock}
>
<div className="mx_ImageView_panel">
2021-06-07 16:12:06 +02:00
{info}
{title}
<div className="mx_ImageView_toolbar">
<AccessibleButton
className="mx_ImageView_button"
title={_t("action|zoom_out")}
onClick={this.onZoomOutClick}
>
<ZoomOutIcon />
</AccessibleButton>
<AccessibleButton
className="mx_ImageView_button"
title={_t("action|zoom_in")}
onClick={this.onZoomInClick}
>
<ZoomInIcon />
</AccessibleButton>
<AccessibleButton
className="mx_ImageView_button"
title={_t("lightbox|rotate_left")}
2021-07-23 10:23:45 +01:00
onClick={this.onRotateCounterClockwiseClick}
>
<RotateLeftIcon />
</AccessibleButton>
<AccessibleButton
className="mx_ImageView_button"
title={_t("lightbox|rotate_right")}
2021-07-23 10:23:45 +01:00
onClick={this.onRotateClockwiseClick}
>
<RotateRightIcon />
</AccessibleButton>
2025-07-17 11:53:11 +02:00
<DownloadButton
url={this.props.src}
fileName={this.props.name}
mxEvent={this.props.mxEvent}
onDownloadReady={this.onDownloadFunctionReady}
/>
2021-06-07 16:12:06 +02:00
{contextMenuButton}
<AccessibleButton
className="mx_ImageView_button mx_ImageView_button_close"
title={_t("action|close")}
2021-07-23 10:23:45 +01:00
onClick={this.props.onFinished}
>
<CloseIcon />
</AccessibleButton>
2021-06-07 16:12:06 +02:00
{this.renderContextMenu()}
2021-02-24 11:15:59 +01:00
</div>
</div>
2021-04-26 13:11:41 +02:00
<div
className="mx_ImageView_image_wrapper"
ref={this.imageWrapper}
onMouseDown={this.props.onFinished}
onMouseMove={this.onMoving}
onMouseUp={this.onEndMoving}
onMouseLeave={this.onEndMoving}
>
<img
src={this.props.src}
style={style}
2021-07-17 08:21:46 +02:00
alt={this.props.name}
2021-04-26 13:30:14 +02:00
ref={this.image}
2021-09-21 17:59:13 +02:00
className={`mx_ImageView_image ${transitionClassName}`}
draggable={true}
onMouseDown={this.onStartMoving}
/>
</div>
</FocusLock>
);
2019-03-29 23:31:15 -07:00
}
}
2025-07-17 11:53:11 +02:00
interface DownloadButtonProps {
url: string;
fileName?: string;
mxEvent?: MatrixEvent;
2025-07-17 11:53:11 +02:00
onDownloadReady?: (download: () => Promise<void>) => void;
}
export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => {
const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);
useEffect(() => {
2025-07-17 11:53:11 +02:00
if (onDownloadReady) onDownloadReady(download);
}, [download, onDownloadReady]);
2025-07-17 11:53:11 +02:00
if (!canDownload) return null;
return (
<AccessibleButton
className="mx_ImageView_button"
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
2025-07-17 11:53:11 +02:00
onClick={download}
disabled={loading}
>
<DownloadIcon />
</AccessibleButton>
);
2025-07-17 11:53:11 +02:00
};
/**
* Wraps MessageTimestampView with a view model synced to the provided props.
* This wrapper can be removed after ImageView has been changed to a function component.
*/
function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new MessageTimestampViewModel(props));
useEffect(() => {
vm.setTimestamp(props.ts);
vm.setDisplayOptions({
showTwelveHour: props.showTwelveHour,
showFullDate: props.showFullDate,
showSeconds: props.showSeconds,
});
vm.setTooltipInhibited(props.inhibitTooltip);
vm.setHref(props.href);
vm.setHandlers({ onClick: props.onClick });
}, [vm, props]);
return <MessageTimestampView vm={vm} className="mx_MessageTimestamp" />;
}