diff --git a/.stylelintrc.js b/.stylelintrc.js index 313102ea83..0e6de7000f 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -4,6 +4,7 @@ module.exports = { "stylelint-scss", ], "rules": { + "color-hex-case": null, "indentation": 4, "comment-empty-line-before": null, "declaration-empty-line-before": null, diff --git a/res/css/_common.scss b/res/css/_common.scss index 0093bde0ab..87fa4578b1 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -303,7 +303,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } .mx_Dialog_lightbox .mx_Dialog_background { - opacity: 0.85; + opacity: $lightbox-background-bg-opacity; background-color: $lightbox-background-bg-color; } @@ -315,6 +315,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { max-width: 100%; max-height: 100%; pointer-events: none; + padding: 0; } .mx_Dialog_header { diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 0a4ed2a194..93ebcc2d56 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -14,139 +14,108 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* This has got to be the most fragile piece of CSS ever written. - But empirically it works on Chrome/FF/Safari - */ - .mx_ImageView { display: flex; width: 100%; height: 100%; - align-items: center; -} - -.mx_ImageView_lhs { - order: 1; - flex: 1 1 10%; - min-width: 60px; - // background-color: #080; - // height: 20px; -} - -.mx_ImageView_content { - order: 2; - /* min-width hack needed for FF */ - min-width: 0px; - height: 90%; - flex: 15 15 0; - display: flex; - align-items: center; - justify-content: center; -} - -.mx_ImageView_content img { - max-width: 100%; - /* XXX: max-height interacts badly with flex on Chrome and doesn't relayout properly until you refresh */ - max-height: 100%; - /* object-fit hack needed for Chrome due to Chrome not re-laying-out until you refresh */ - object-fit: contain; - /* background-image: url('$(res)/img/trans.png'); */ - pointer-events: all; -} - -.mx_ImageView_labelWrapper { - position: absolute; - top: 0px; - right: 0px; - height: 100%; - overflow: auto; - pointer-events: all; -} - -.mx_ImageView_label { - text-align: left; - display: flex; - justify-content: center; flex-direction: column; - padding-left: 30px; - padding-right: 30px; - min-height: 100%; - max-width: 240px; +} + +.mx_ImageView_image_wrapper { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + overflow: hidden; +} + +.mx_ImageView_image { + pointer-events: all; + max-width: 95%; + max-height: 95%; +} + +.mx_ImageView_panel { + width: 100%; + height: 68px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.mx_ImageView_info_wrapper { + pointer-events: all; + padding-left: 32px; + display: flex; + flex-direction: row; + align-items: center; color: $lightbox-fg-color; } -.mx_ImageView_cancel { - position: absolute; - // hack for mx_Dialog having a top padding of 40px - top: 40px; - right: 0px; - padding-top: 35px; - padding-right: 35px; - cursor: pointer; +.mx_ImageView_info { + padding-left: 12px; + display: flex; + flex-direction: column; } -.mx_ImageView_rotateClockwise { - position: absolute; - top: 40px; - right: 70px; - padding-top: 35px; - cursor: pointer; +.mx_ImageView_info_sender { + font-weight: bold; } -.mx_ImageView_rotateCounterClockwise { - position: absolute; - top: 40px; - right: 105px; - padding-top: 35px; - cursor: pointer; -} - -.mx_ImageView_name { - font-size: $font-18px; - margin-bottom: 6px; - word-wrap: break-word; -} - -.mx_ImageView_metadata { - font-size: $font-15px; - opacity: 0.5; -} - -.mx_ImageView_download { - display: table; - margin-top: 24px; - margin-bottom: 6px; - border-radius: 5px; - background-color: $lightbox-bg-color; - font-size: $font-14px; - padding: 9px; - border: 1px solid $lightbox-border-color; -} - -.mx_ImageView_size { - font-size: $font-11px; -} - -.mx_ImageView_link { - color: $lightbox-fg-color !important; - text-decoration: none !important; +.mx_ImageView_toolbar { + padding-right: 16px; + pointer-events: all; + display: flex; + align-items: center; } .mx_ImageView_button { - font-size: $font-15px; - opacity: 0.5; - margin-top: 18px; - cursor: pointer; + margin-left: 24px; + display: block; + + &::before { + content: ''; + height: 22px; + width: 22px; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + display: block; + background-color: $icon-button-color; + } } -.mx_ImageView_shim { - height: 30px; +.mx_ImageView_button_rotateCW::before { + mask-image: url('$(res)/img/image-view/rotate-cw.svg'); } -.mx_ImageView_rhs { - order: 3; - flex: 1 1 10%; - min-width: 300px; - // background-color: #800; - // height: 20px; +.mx_ImageView_button_rotateCCW::before { + mask-image: url('$(res)/img/image-view/rotate-ccw.svg'); +} + +.mx_ImageView_button_zoomOut::before { + mask-image: url('$(res)/img/image-view/zoom-out.svg'); +} + +.mx_ImageView_button_zoomIn::before { + mask-image: url('$(res)/img/image-view/zoom-in.svg'); +} + +.mx_ImageView_button_download::before { + mask-image: url('$(res)/img/image-view/download.svg'); +} + +.mx_ImageView_button_more::before { + mask-image: url('$(res)/img/image-view/more.svg'); +} + +.mx_ImageView_button_close { + border-radius: 100%; + background: #21262c; // same on all themes + &::before { + width: 32px; + height: 32px; + mask-image: url('$(res)/img/image-view/close.svg'); + mask-size: 40%; + } } diff --git a/res/img/cancel-white.svg b/res/img/cancel-white.svg deleted file mode 100644 index 65e14c2fbc..0000000000 --- a/res/img/cancel-white.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file diff --git a/res/img/image-view/close.svg b/res/img/image-view/close.svg new file mode 100644 index 0000000000..d603b7f5cc --- /dev/null +++ b/res/img/image-view/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/download.svg b/res/img/image-view/download.svg new file mode 100644 index 0000000000..c51deed876 --- /dev/null +++ b/res/img/image-view/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/more.svg b/res/img/image-view/more.svg new file mode 100644 index 0000000000..4f5fa6f9b9 --- /dev/null +++ b/res/img/image-view/more.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/rotate-ccw.svg b/res/img/image-view/rotate-ccw.svg new file mode 100644 index 0000000000..85ea3198de --- /dev/null +++ b/res/img/image-view/rotate-ccw.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/rotate-cw.svg b/res/img/image-view/rotate-cw.svg new file mode 100644 index 0000000000..e337f3420e --- /dev/null +++ b/res/img/image-view/rotate-cw.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/zoom-in.svg b/res/img/image-view/zoom-in.svg new file mode 100644 index 0000000000..c0816d489e --- /dev/null +++ b/res/img/image-view/zoom-in.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/zoom-out.svg b/res/img/image-view/zoom-out.svg new file mode 100644 index 0000000000..0539e8c81a --- /dev/null +++ b/res/img/image-view/zoom-out.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/rotate-ccw.svg b/res/img/rotate-ccw.svg deleted file mode 100644 index 3924eca040..0000000000 --- a/res/img/rotate-ccw.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/rotate-cw.svg b/res/img/rotate-cw.svg deleted file mode 100644 index 91021c96d8..0000000000 --- a/res/img/rotate-cw.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index cf1fd17e58..bd7057c3e4 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -85,6 +85,7 @@ $dialog-close-fg-color: #9fa9ba; $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 85%; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #21262c; @@ -242,7 +243,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index ff58314bdd..9b2365a621 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -83,6 +83,7 @@ $dialog-close-fg-color: #9fa9ba; $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 85%; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #e7e7e7; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 121366decb..7cb7082c4e 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -127,6 +127,7 @@ $dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 95%; $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index f082247754..dc26c4d652 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -118,6 +118,7 @@ $dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 95%; $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 9b1edf0775..e4a1175d88 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -97,7 +97,7 @@ export function formatFullDateNoTime(date: Date): string { }); } -export function formatFullDate(date: Date, showTwelveHour = false): string { +export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { @@ -105,7 +105,7 @@ export function formatFullDate(date: Date, showTwelveHour = false): string { monthName: months[date.getMonth()], day: date.getDate(), fullYear: date.getFullYear(), - time: formatFullTime(date, showTwelveHour), + time: showSeconds ? formatFullTime(date, showTwelveHour) : formatTime(date, showTwelveHour), }); } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 31245b44b7..ad0eb45a52 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -129,7 +129,7 @@ export default class RoomAvatar extends React.Component { name: this.props.room.name, }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); }; public render() { diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 56f070ba36..f86cd26f32 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -52,6 +52,9 @@ export default class MessageContextMenu extends React.Component { /* callback called when the menu is dismissed */ onFinished: PropTypes.func, + + /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ + onCloseDialog: PropTypes.func, }; state = { @@ -141,6 +144,7 @@ export default class MessageContextMenu extends React.Component { const cli = MatrixClientPeg.get(); try { + if (this.props.onCloseDialog) this.props.onCloseDialog(); await cli.redactEvent( this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), @@ -190,6 +194,7 @@ export default class MessageContextMenu extends React.Component { }; onForwardClick = () => { + if (this.props.onCloseDialog) this.props.onCloseDialog(); dis.dispatch({ action: 'forward_event', event: this.props.mxEvent, diff --git a/src/components/views/elements/ImageView.js b/src/components/views/elements/ImageView.js deleted file mode 100644 index 96b6de832d..0000000000 --- a/src/components/views/elements/ImageView.js +++ /dev/null @@ -1,235 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {formatDate} from '../../../DateUtils'; -import { _t } from '../../../languageHandler'; -import filesize from "filesize"; -import AccessibleButton from "./AccessibleButton"; -import Modal from "../../../Modal"; -import * as sdk from "../../../index"; -import {Key} from "../../../Keyboard"; -import FocusLock from "react-focus-lock"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; - -@replaceableComponent("views.elements.ImageView") -export default class ImageView extends React.Component { - static propTypes = { - src: PropTypes.string.isRequired, // the source of the image being displayed - name: PropTypes.string, // the main title ('name') for the image - link: PropTypes.string, // the link (if any) applied to the name of the image - width: PropTypes.number, // width of the image src in pixels - height: PropTypes.number, // height of the image src in pixels - fileSize: PropTypes.number, // size of the image src in bytes - onFinished: PropTypes.func.isRequired, // callback when the lightbox is dismissed - - // 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. - mxEvent: PropTypes.object, - }; - - constructor(props) { - super(props); - this.state = { rotationDegrees: 0 }; - } - - onKeyDown = (ev) => { - if (ev.key === Key.ESCAPE) { - ev.stopPropagation(); - ev.preventDefault(); - this.props.onFinished(); - } - }; - - onRedactClick = () => { - const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); - Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, { - onFinished: (proceed) => { - if (!proceed) return; - this.props.onFinished(); - MatrixClientPeg.get().redactEvent( - this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), - ).catch(function(e) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - // display error message stating you couldn't delete this. - const code = e.errcode || e.statusCode; - Modal.createTrackedDialog('You cannot delete this image.', '', ErrorDialog, { - title: _t('Error'), - description: _t('You cannot delete this image. (%(code)s)', {code: code}), - }); - }); - }, - }); - }; - - getName() { - let name = this.props.name; - if (name && this.props.link) { - name = { name }; - } - return name; - } - - rotateCounterClockwise = () => { - const cur = this.state.rotationDegrees; - const rotationDegrees = (cur - 90) % 360; - this.setState({ rotationDegrees }); - }; - - rotateClockwise = () => { - const cur = this.state.rotationDegrees; - const rotationDegrees = (cur + 90) % 360; - this.setState({ rotationDegrees }); - }; - - render() { -/* - // In theory max-width: 80%, max-height: 80% on the CSS should work - // but in practice, it doesn't, so do it manually: - - var width = this.props.width || 500; - var height = this.props.height || 500; - - var maxWidth = document.documentElement.clientWidth * 0.8; - var maxHeight = document.documentElement.clientHeight * 0.8; - - var widthFrac = width / maxWidth; - var heightFrac = height / maxHeight; - - var displayWidth; - var displayHeight; - if (widthFrac > heightFrac) { - displayWidth = Math.min(width, maxWidth); - displayHeight = (displayWidth / width) * height; - } else { - displayHeight = Math.min(height, maxHeight); - displayWidth = (displayHeight / height) * width; - } - - var style = { - width: displayWidth, - height: displayHeight - }; -*/ - let style = {}; - let res; - - if (this.props.width && this.props.height) { - style = { - width: this.props.width, - height: this.props.height, - }; - res = style.width + "x" + style.height + "px"; - } - - let size; - if (this.props.fileSize) { - size = filesize(this.props.fileSize); - } - - let sizeRes; - if (size && res) { - sizeRes = size + ", " + res; - } else { - sizeRes = size || res; - } - - let mayRedact = false; - const showEventMeta = !!this.props.mxEvent; - - let eventMeta; - if (showEventMeta) { - // Figure out the sender, defaulting to mxid - let sender = this.props.mxEvent.getSender(); - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - if (room) { - mayRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId); - const member = room.getMember(sender); - if (member) sender = member.name; - } - - eventMeta = (
- { _t('Uploaded on %(date)s by %(user)s', { - date: formatDate(new Date(this.props.mxEvent.getTs())), - user: sender, - }) } -
); - } - - let eventRedact; - if (mayRedact) { - eventRedact = (
- { _t('Remove') } -
); - } - - const rotationDegrees = this.state.rotationDegrees; - const effectiveStyle = {transform: `rotate(${rotationDegrees}deg)`, ...style}; - - return ( - -
-
-
- -
-
- - { - - - { - - - { - -
-
-
- { this.getName() } -
- { eventMeta } - -
- { _t('Download this file') }
- { sizeRes } -
-
- { eventRedact } -
-
-
-
-
-
-
-
- ); - } -} diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx new file mode 100644 index 0000000000..dad62521da --- /dev/null +++ b/src/components/views/elements/ImageView.tsx @@ -0,0 +1,439 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020, 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { createRef } from 'react'; +import { _t } from '../../../languageHandler'; +import AccessibleTooltipButton from "./AccessibleTooltipButton"; +import {Key} from "../../../Keyboard"; +import FocusLock from "react-focus-lock"; +import MemberAvatar from "../avatars/MemberAvatar"; +import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton"; +import MessageContextMenu from "../context_menus/MessageContextMenu"; +import {aboveLeftOf, ContextMenu} from '../../structures/ContextMenu'; +import MessageTimestamp from "../messages/MessageTimestamp"; +import SettingsStore from "../../../settings/SettingsStore"; +import {formatFullDate} from "../../../DateUtils"; +import dis from '../../../dispatcher/dispatcher'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks" +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; + +const MIN_ZOOM = 100; +const MAX_ZOOM = 300; +// This is used for the buttons +const ZOOM_STEP = 10; +// This is used for mouse wheel events +const ZOOM_COEFFICIENT = 10; +// If we have moved only this much we can zoom +const ZOOM_DISTANCE = 10; + + +interface IProps { + 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 + onFinished(): void, // callback when the lightbox is dismissed + + // 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. + mxEvent: MatrixEvent, + permalinkCreator: RoomPermalinkCreator, +} + +interface IState { + rotation: number, + zoom: number, + translationX: number, + translationY: number, + moving: boolean, + contextMenuDisplayed: boolean, +} + +@replaceableComponent("views.elements.ImageView") +export default class ImageView extends React.Component { + constructor(props) { + super(props); + this.state = { + rotation: 0, + zoom: MIN_ZOOM, + translationX: 0, + translationY: 0, + moving: false, + contextMenuDisplayed: false, + }; + } + + // XXX: Refs to functional components + private contextMenuButton = createRef(); + private focusLock = createRef(); + + private initX = 0; + private initY = 0; + private lastX = 0; + private lastY = 0; + private previousX = 0; + private previousY = 0; + + componentDidMount() { + // 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 }); + } + + componentWillUnmount() { + this.focusLock.current.removeEventListener('wheel', this.onWheel); + } + + private onKeyDown = (ev: KeyboardEvent) => { + if (ev.key === Key.ESCAPE) { + ev.stopPropagation(); + ev.preventDefault(); + this.props.onFinished(); + } + }; + + private onWheel = (ev: WheelEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + const newZoom = this.state.zoom - (ev.deltaY * ZOOM_COEFFICIENT); + + if (newZoom <= MIN_ZOOM) { + this.setState({ + zoom: MIN_ZOOM, + translationX: 0, + translationY: 0, + }); + return; + } + if (newZoom >= MAX_ZOOM) { + this.setState({zoom: MAX_ZOOM}); + return; + } + + this.setState({ + zoom: newZoom, + }); + }; + + private onRotateCounterClockwiseClick = () => { + const cur = this.state.rotation; + const rotationDegrees = cur - 90; + this.setState({ rotation: rotationDegrees }); + }; + + private onRotateClockwiseClick = () => { + const cur = this.state.rotation; + const rotationDegrees = cur + 90; + this.setState({ rotation: rotationDegrees }); + }; + + private onZoomInClick = () => { + if (this.state.zoom >= MAX_ZOOM) { + this.setState({zoom: MAX_ZOOM}); + return; + } + + this.setState({ + zoom: this.state.zoom + ZOOM_STEP, + }); + }; + + private onZoomOutClick = () => { + if (this.state.zoom <= MIN_ZOOM) { + this.setState({ + zoom: MIN_ZOOM, + translationX: 0, + translationY: 0, + }); + return; + } + this.setState({ + zoom: this.state.zoom - ZOOM_STEP, + }); + }; + + private onDownloadClick = () => { + const a = document.createElement("a"); + a.href = this.props.src; + a.download = this.props.name; + a.target = "_blank"; + a.click(); + }; + + private onOpenContextMenu = () => { + this.setState({ + contextMenuDisplayed: true, + }); + }; + + private onCloseContextMenu = () => { + this.setState({ + contextMenuDisplayed: false, + }); + }; + + private onPermalinkClicked = (ev: React.MouseEvent) => { + // 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. + ev.preventDefault(); + dis.dispatch({ + action: 'view_room', + event_id: this.props.mxEvent.getId(), + highlighted: true, + room_id: this.props.mxEvent.getRoomId(), + }); + this.props.onFinished(); + }; + + private onStartMoving = (ev: React.MouseEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + // Zoom in if we are completely zoomed out + if (this.state.zoom === MIN_ZOOM) { + this.setState({zoom: MAX_ZOOM}); + return; + } + + this.setState({moving: true}); + this.previousX = this.state.translationX; + this.previousY = this.state.translationY; + this.initX = ev.pageX - this.lastX; + this.initY = ev.pageY - this.lastY; + }; + + private onMoving = (ev: React.MouseEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + if (!this.state.moving) return; + + this.lastX = ev.pageX - this.initX; + this.lastY = ev.pageY - this.initY; + this.setState({ + translationX: this.lastX, + translationY: this.lastY, + }); + }; + + private onEndMoving = () => { + // Zoom out if we haven't moved much + if ( + this.state.moving === true && + Math.abs(this.state.translationX - this.previousX) < ZOOM_DISTANCE && + Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE + ) { + this.setState({ + zoom: MIN_ZOOM, + translationX: 0, + translationY: 0, + }); + } + this.setState({moving: false}); + }; + + private renderContextMenu() { + let contextMenu = null; + if (this.state.contextMenuDisplayed) { + contextMenu = ( + + + + ); + } + + return ( + + { contextMenu } + + ); + } + + render() { + const showEventMeta = !!this.props.mxEvent; + + let cursor; + if (this.state.moving) { + cursor= "grabbing"; + } else if (this.state.zoom === MIN_ZOOM) { + cursor = "zoom-in"; + } else { + cursor = "zoom-out"; + } + const rotationDegrees = this.state.rotation + "deg"; + const zoomPercentage = this.state.zoom/100; + const translatePixelsX = this.state.translationX + "px"; + const translatePixelsY = this.state.translationY + "px"; + // 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 = { + cursor: cursor, + transition: this.state.moving ? null : "transform 200ms ease 0s", + transform: `translateX(${translatePixelsX}) + translateY(${translatePixelsY}) + scale(${zoomPercentage}) + rotate(${rotationDegrees})`, + }; + + let info; + if (showEventMeta) { + const mxEvent = this.props.mxEvent; + const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); + let permalink = "#"; + if (this.props.permalinkCreator) { + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + + const senderName = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); + const sender = ( +
+ {senderName} +
+ ); + const messageTimestamp = ( + + + + ); + const avatar = ( + + ); + + info = ( +
+ {avatar} +
+ {sender} + {messageTimestamp} +
+
+ ); + } 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 + info = ( +
+ ); + } + + let contextMenuButton; + if (this.props.mxEvent) { + contextMenuButton = ( + + ); + } + + return ( + +
+ {info} +
+ + + + + + + + + + + {contextMenuButton} + + + {this.renderContextMenu()} +
+
+
+ +
+
+ ); + } +} diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 3683818027..5af2063c84 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -41,6 +41,9 @@ export default class MImageBody extends React.Component { /* the maximum image height to use */ maxImageHeight: PropTypes.number, + + /* the permalinkCreator */ + permalinkCreator: PropTypes.object, }; static contextType = MatrixClientContext; @@ -106,6 +109,7 @@ export default class MImageBody extends React.Component { src: httpUrl, name: content.body && content.body.length > 0 ? content.body : _t('Attachment'), mxEvent: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, }; if (content.info) { @@ -114,7 +118,7 @@ export default class MImageBody extends React.Component { params.fileSize = content.info.size; } - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); } } diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 28c2f8f9b9..60f7631c8e 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -46,6 +46,9 @@ export default class MessageEvent extends React.Component { /* the maximum image height to use, if the event is an image */ maxImageHeight: PropTypes.number, + + /* the permalinkCreator */ + permalinkCreator: PropTypes.object, }; constructor(props) { @@ -126,6 +129,7 @@ export default class MessageEvent extends React.Component { editState={this.props.editState} onHeightChanged={this.props.onHeightChanged} onMessageAllowed={this.onTileUpdate} + permalinkCreator={this.props.permalinkCreator} />; } } diff --git a/src/components/views/messages/MessageTimestamp.js b/src/components/views/messages/MessageTimestamp.js index c9bdb8937e..a7f350adcd 100644 --- a/src/components/views/messages/MessageTimestamp.js +++ b/src/components/views/messages/MessageTimestamp.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {formatFullDate, formatTime} from '../../../DateUtils'; +import {formatFullDate, formatTime, formatFullTime} from '../../../DateUtils'; import {replaceableComponent} from "../../../utils/replaceableComponent"; @replaceableComponent("views.messages.MessageTimestamp") @@ -25,13 +25,24 @@ export default class MessageTimestamp extends React.Component { static propTypes = { ts: PropTypes.number.isRequired, showTwelveHour: PropTypes.bool, + showFullDate: PropTypes.bool, + showSeconds: PropTypes.bool, }; render() { const date = new Date(this.props.ts); + let timestamp; + if (this.props.showFullDate) { + timestamp = formatFullDate(date, this.props.showTwelveHour, this.props.showSeconds); + } else if (this.props.showSeconds) { + timestamp = formatFullTime(date, this.props.showTwelveHour); + } else { + timestamp = formatTime(date, this.props.showTwelveHour); + } + return ( - { formatTime(date, this.props.showTwelveHour) } + {timestamp} ); } diff --git a/src/components/views/messages/RoomAvatarEvent.js b/src/components/views/messages/RoomAvatarEvent.js index 00aaf9bfda..41eada3193 100644 --- a/src/components/views/messages/RoomAvatarEvent.js +++ b/src/components/views/messages/RoomAvatarEvent.js @@ -49,7 +49,7 @@ export default class RoomAvatarEvent extends React.Component { src: httpUrl, name: text, }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); }; render() { diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index b1aff9ccd0..be152d91bd 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1432,7 +1432,7 @@ const UserInfoHeader: React.FC<{ name: member.name, }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); }, [member]); const avatarElement = ( diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 46c5caa926..a3474161d7 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -1101,6 +1101,7 @@ export default class EventTile extends React.Component { highlights={this.props.highlights} highlightLink={this.props.highlightLink} showUrlPreview={this.props.showUrlPreview} + permalinkCreator={this.props.permalinkCreator} onHeightChanged={this.props.onHeightChanged} /> { keyRequestInfo } { reactionsRow } diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js index 536abf57fc..c04bb6cb7c 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.js @@ -96,7 +96,7 @@ export default class LinkPreviewWidget extends React.Component { link: this.props.link, }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); }; render() { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6dd0f995f3..02236f9997 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1915,14 +1915,11 @@ "collapse": "collapse", "expand": "expand", "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", - "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", - "You cannot delete this image. (%(code)s)": "You cannot delete this image. (%(code)s)", - "Uploaded on %(date)s by %(user)s": "Uploaded on %(date)s by %(user)s", - "Rotate Left": "Rotate Left", - "Rotate counter-clockwise": "Rotate counter-clockwise", "Rotate Right": "Rotate Right", - "Rotate clockwise": "Rotate clockwise", - "Download this file": "Download this file", + "Rotate Left": "Rotate Left", + "Zoom out": "Zoom out", + "Zoom in": "Zoom in", + "Download": "Download", "Information": "Information", "View message": "View message", "Language Dropdown": "Language Dropdown", @@ -2800,7 +2797,6 @@ "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.": "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.", "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Keep a copy of it somewhere secure, like a password manager or even a safe.", "Your Security Key": "Your Security Key", - "Download": "Download", "Your Security Key has been copied to your clipboard, paste it to:": "Your Security Key has been copied to your clipboard, paste it to:", "Your Security Key is in your Downloads folder.": "Your Security Key is in your Downloads folder.", "Print it and store it somewhere safe": "Print it and store it somewhere safe",