diff --git a/res/css/_components.scss b/res/css/_components.scss index bb224462586..4efc3f23163 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -164,6 +164,7 @@ @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; +@import "./views/messages/_MImageReplyBody.scss"; @import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @@ -213,6 +214,7 @@ @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_ReplyPreview.scss"; +@import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index bf44a117289..af8ca956ba7 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -18,20 +18,46 @@ limitations under the License. margin-top: 0; } -.mx_ReplyThread .mx_DateSeparator { - font-size: 1em !important; - margin-top: 0; - margin-bottom: 0; - padding-bottom: 1px; - bottom: -5px; -} - .mx_ReplyThread_show { cursor: pointer; } blockquote.mx_ReplyThread { margin-left: 0; + margin-right: 0; + margin-bottom: 8px; padding-left: 10px; - border-left: 4px solid $blockquote-bar-color; + border-left: 4px solid $button-bg-color; + + &.mx_ReplyThread_color1 { + border-left-color: $username-variant1-color; + } + + &.mx_ReplyThread_color2 { + border-left-color: $username-variant2-color; + } + + &.mx_ReplyThread_color3 { + border-left-color: $username-variant3-color; + } + + &.mx_ReplyThread_color4 { + border-left-color: $username-variant4-color; + } + + &.mx_ReplyThread_color5 { + border-left-color: $username-variant5-color; + } + + &.mx_ReplyThread_color6 { + border-left-color: $username-variant6-color; + } + + &.mx_ReplyThread_color7 { + border-left-color: $username-variant7-color; + } + + &.mx_ReplyThread_color8 { + border-left-color: $username-variant8-color; + } } diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index c215d69ec27..b91c461ce53 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -83,12 +83,12 @@ limitations under the License. mask-size: cover; mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); background-color: $message-body-panel-icon-fg-color; - width: 13px; + width: 15px; height: 15px; position: absolute; top: 8px; - left: 9px; + left: 8px; } } diff --git a/res/css/views/messages/_MImageReplyBody.scss b/res/css/views/messages/_MImageReplyBody.scss new file mode 100644 index 00000000000..70c53f8c9c4 --- /dev/null +++ b/res/css/views/messages/_MImageReplyBody.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 Tulir Asokan + +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. +*/ + +.mx_MImageReplyBody { + display: flex; + + .mx_MImageBody_thumbnail_container { + flex: 1; + margin-right: 4px; + } + + .mx_MImageReplyBody_info { + flex: 1; + + .mx_MImageReplyBody_sender { + grid-area: sender; + } + + .mx_MImageReplyBody_filename { + grid-area: filename; + } + } +} + diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index 10f8e21e43c..c1fe1d9a8ba 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -29,12 +29,16 @@ limitations under the License. } .mx_ReplyPreview_header { - margin: 12px; + margin: 8px; color: $primary-fg-color; font-weight: 400; opacity: 0.4; } +.mx_ReplyPreview_tile { + margin: 0 8px; +} + .mx_ReplyPreview_title { float: left; } @@ -42,6 +46,7 @@ limitations under the License. .mx_ReplyPreview_cancel { float: right; cursor: pointer; + display: flex; } .mx_ReplyPreview_clear { diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss new file mode 100644 index 00000000000..c8f76ee9951 --- /dev/null +++ b/res/css/views/rooms/_ReplyTile.scss @@ -0,0 +1,123 @@ +/* +Copyright 2020 Tulir Asokan + +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. +*/ + +.mx_ReplyTile { + padding-top: 2px; + padding-bottom: 2px; + font-size: $font-14px; + position: relative; + line-height: $font-16px; + + &.mx_ReplyTile_audio .mx_MFileBody_info_icon::before { + mask-image: url("$(res)/img/element-icons/speaker.svg"); + } + + &.mx_ReplyTile_video .mx_MFileBody_info_icon::before { + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } + + .mx_MFileBody { + .mx_MFileBody_info { + margin: 5px 0; + } + + .mx_MFileBody_download { + display: none; + } + } +} + +.mx_ReplyTile > a { + display: flex; + flex-direction: column; + text-decoration: none; + color: $primary-fg-color; +} + +.mx_ReplyTile .mx_RedactedBody { + padding: 4px 0 2px 20px; + + &::before { + height: 13px; + width: 13px; + top: 5px; + } +} + +// We do reply size limiting with CSS to avoid duplicating the TextualBody component. +.mx_ReplyTile .mx_EventTile_content { + $reply-lines: 2; + $line-height: $font-22px; + + pointer-events: none; + + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: $reply-lines; + line-height: $line-height; + + .mx_EventTile_body.mx_EventTile_bigEmoji { + line-height: $line-height !important; + // Override the big emoji override + font-size: $font-14px !important; + } + + // Hide line numbers + .mx_EventTile_lineNumbers { + display: none; + } + + // Hack to cut content in
 tags too
+    .mx_EventTile_pre_container > pre {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        display: -webkit-box;
+        -webkit-box-orient: vertical;
+        -webkit-line-clamp: $reply-lines;
+        padding: 4px;
+    }
+
+    .markdown-body blockquote,
+    .markdown-body dl,
+    .markdown-body ol,
+    .markdown-body p,
+    .markdown-body pre,
+    .markdown-body table,
+    .markdown-body ul {
+        margin-bottom: 4px;
+    }
+}
+
+.mx_ReplyTile.mx_ReplyTile_info {
+    padding-top: 0;
+}
+
+.mx_ReplyTile .mx_SenderProfile {
+    color: $primary-fg-color;
+    font-size: $font-14px;
+    display: inline-block; /* anti-zalgo, with overflow hidden */
+    overflow: hidden;
+    cursor: pointer;
+    padding-left: 0; /* left gutter */
+    padding-bottom: 0;
+    padding-top: 0;
+    margin: 0;
+    line-height: $font-17px;
+    /* the next three lines, along with overflow hidden, truncate long display names */
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
diff --git a/res/img/element-icons/speaker.svg b/res/img/element-icons/speaker.svg
new file mode 100644
index 00000000000..fd811d2cda8
--- /dev/null
+++ b/res/img/element-icons/speaker.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx
index 35d9909f668..16263e52042 100644
--- a/src/components/views/elements/ImageView.tsx
+++ b/src/components/views/elements/ImageView.tsx
@@ -33,6 +33,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { normalizeWheelEvent } from "../../../utils/Mouse";
+import { IDialogProps } from '../dialogs/IDialogProps';
 
 // Max scale to keep gaps around the image
 const MAX_SCALE = 0.95;
@@ -43,14 +44,13 @@ const ZOOM_COEFFICIENT = 0.0025;
 // If we have moved only this much we can zoom
 const ZOOM_DISTANCE = 10;
 
-interface IProps {
+interface IProps extends IDialogProps {
     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
diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js
index 4dcdf708452..89427515e2c 100644
--- a/src/components/views/elements/ReplyThread.js
+++ b/src/components/views/elements/ReplyThread.js
@@ -15,23 +15,23 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 import React from 'react';
-import * as sdk from '../../../index';
 import { _t } from '../../../languageHandler';
 import PropTypes from 'prop-types';
 import dis from '../../../dispatcher/dispatcher';
-import { wantsDateSeparator } from '../../../DateUtils';
 import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
 import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
 import SettingsStore from "../../../settings/SettingsStore";
 import { LayoutPropType } from "../../../settings/Layout";
 import escapeHtml from "escape-html";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import { getUserNameColorClass } from "../../../utils/FormattingUtils";
 import { Action } from "../../../dispatcher/actions";
 import sanitizeHtml from "sanitize-html";
-import { UIFeature } from "../../../settings/UIFeature";
 import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
-import { TileShape } from "../rooms/EventTile";
+import Spinner from './Spinner';
+import ReplyTile from "../rooms/ReplyTile";
+import Pill from './Pill';
 
 // This component does no cycle detection, simply because the only way to make such a cycle would be to
 // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
@@ -69,10 +69,7 @@ export default class ReplyThread extends React.Component {
         };
 
         this.unmounted = false;
-        this.context.on("Event.replaced", this.onEventReplaced);
         this.room = this.context.getRoom(this.props.parentEv.getRoomId());
-        this.room.on("Room.redaction", this.onRoomRedaction);
-        this.room.on("Room.redactionCancelled", this.onRoomRedaction);
 
         this.onQuoteClick = this.onQuoteClick.bind(this);
         this.canCollapse = this.canCollapse.bind(this);
@@ -238,36 +235,8 @@ export default class ReplyThread extends React.Component {
 
     componentWillUnmount() {
         this.unmounted = true;
-        this.context.removeListener("Event.replaced", this.onEventReplaced);
-        if (this.room) {
-            this.room.removeListener("Room.redaction", this.onRoomRedaction);
-            this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
-        }
     }
 
-    updateForEventId = (eventId) => {
-        if (this.state.events.some(event => event.getId() === eventId)) {
-            this.forceUpdate();
-        }
-    };
-
-    onEventReplaced = (ev) => {
-        if (this.unmounted) return;
-
-        // If one of the events we are rendering gets replaced, force a re-render
-        this.updateForEventId(ev.getId());
-    };
-
-    onRoomRedaction = (ev) => {
-        if (this.unmounted) return;
-
-        const eventId = ev.getAssociatedId();
-        if (!eventId) return;
-
-        // If one of the events we are rendering gets redacted, force a re-render
-        this.updateForEventId(eventId);
-    };
-
     async initialize() {
         const { parentEv } = this.props;
         // at time of making this component we checked that props.parentEv has a parentEventId
@@ -337,6 +306,10 @@ export default class ReplyThread extends React.Component {
         dis.fire(Action.FocusSendMessageComposer);
     }
 
+    getReplyThreadColorClass(ev) {
+        return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread");
+    }
+
     render() {
         let header = null;
 
@@ -349,9 +322,8 @@ export default class ReplyThread extends React.Component {
             ;
         } else if (this.state.loadedEv) {
             const ev = this.state.loadedEv;
-            const Pill = sdk.getComponent('elements.Pill');
             const room = this.context.getRoom(ev.getRoomId());
-            header = 
+ header =
{ _t('In reply to ', {}, { 'a': (sub) => { sub }, @@ -367,33 +339,15 @@ export default class ReplyThread extends React.Component { }
; } else if (this.state.loading) { - const Spinner = sdk.getComponent("elements.Spinner"); header = ; } - const EventTile = sdk.getComponent('views.rooms.EventTile'); - const DateSeparator = sdk.getComponent('messages.DateSeparator'); const evTiles = this.state.events.map((ev) => { - let dateSep = null; - - if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) { - dateSep = ; - } - - return
- { dateSep } - +
; }); diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 660981de846..9236c77e8dc 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -90,6 +90,35 @@ function computedStyle(element) { return cssText; } +/** + * Extracts a human readable label for the file attachment to use as + * link text. + * + * @param {Object} content The "content" key of the matrix event. + * @param {boolean} withSize Whether to include size information. Default true. + * @return {string} the human readable link text for the attachment. + */ +export function presentableTextForFile(content, withSize = true) { + let linkText = _t("Attachment"); + if (content.body && content.body.length > 0) { + // The content body should be the name of the file including a + // file extension. + linkText = content.body; + } + + if (content.info && content.info.size && withSize) { + // If we know the size of the file then add it as human readable + // string to the end of the link text so that the user knows how + // big a file they are downloading. + // The content.info also contains a MIME-type but we don't display + // it since it is "ugly", users generally aren't aware what it + // means and the type of the attachment can usually be inferrered + // from the file extension. + linkText += ' (' + filesize(content.info.size) + ')'; + } + return linkText; +} + @replaceableComponent("views.messages.MFileBody") export default class MFileBody extends React.Component { static propTypes = { @@ -120,35 +149,6 @@ export default class MFileBody extends React.Component { this._dummyLink = createRef(); } - /** - * Extracts a human readable label for the file attachment to use as - * link text. - * - * @param {Object} content The "content" key of the matrix event. - * @param {boolean} withSize Whether to include size information. Default true. - * @return {string} the human readable link text for the attachment. - */ - presentableTextForFile(content, withSize = true) { - let linkText = _t("Attachment"); - if (content.body && content.body.length > 0) { - // The content body should be the name of the file including a - // file extension. - linkText = content.body; - } - - if (content.info && content.info.size && withSize) { - // If we know the size of the file then add it as human readable - // string to the end of the link text so that the user knows how - // big a file they are downloading. - // The content.info also contains a MIME-type but we don't display - // it since it is "ugly", users generally aren't aware what it - // means and the type of the attachment can usually be inferrered - // from the file extension. - linkText += ' (' + filesize(content.info.size) + ')'; - } - return linkText; - } - _getContentUrl() { const media = mediaFromContent(this.props.mxEvent.getContent()); return media.srcHttp; @@ -162,7 +162,7 @@ export default class MFileBody extends React.Component { render() { const content = this.props.mxEvent.getContent(); - const text = this.presentableTextForFile(content); + const text = presentableTextForFile(content); const isEncrypted = content.file !== undefined; const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const contentUrl = this._getContentUrl(); @@ -174,7 +174,9 @@ export default class MFileBody extends React.Component { placeholder = (
- {this.presentableTextForFile(content, false)} + + { presentableTextForFile(content, false) } +
); } diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.tsx similarity index 77% rename from src/components/views/messages/MImageBody.js rename to src/components/views/messages/MImageBody.tsx index 6da4aa1494e..96c8652aeec 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.tsx @@ -16,13 +16,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; +import React, { ComponentProps, createRef } from 'react'; import { Blurhash } from "react-blurhash"; import MFileBody from './MFileBody'; import Modal from '../../../Modal'; -import * as sdk from '../../../index'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; @@ -31,36 +29,49 @@ import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; import { BLURHASH_FIELD } from "../../../ContentMessages"; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; +import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent'; +import ImageView from '../elements/ImageView'; +import { SyncState } from 'matrix-js-sdk/src/sync.api'; + +export interface IProps { + /* the MatrixEvent to show */ + mxEvent: MatrixEvent; + /* called when the image has loaded */ + onHeightChanged(): void; + + /* the maximum image height to use */ + maxImageHeight?: number; + + /* the permalinkCreator */ + permalinkCreator?: RoomPermalinkCreator; +} -@replaceableComponent("views.messages.MImageBody") -export default class MImageBody extends React.Component { - static propTypes = { - /* the MatrixEvent to show */ - mxEvent: PropTypes.object.isRequired, - - /* called when the image has loaded */ - onHeightChanged: PropTypes.func.isRequired, - - /* the maximum image height to use */ - maxImageHeight: PropTypes.number, - - /* the permalinkCreator */ - permalinkCreator: PropTypes.object, +interface IState { + decryptedUrl?: string; + decryptedThumbnailUrl?: string; + decryptedBlob?: Blob; + error; + imgError: boolean; + imgLoaded: boolean; + loadedImageDimensions?: { + naturalWidth: number; + naturalHeight: number; }; + hover: boolean; + showImage: boolean; +} +@replaceableComponent("views.messages.MImageBody") +export default class MImageBody extends React.Component { static contextType = MatrixClientContext; + private unmounted = true; + private image = createRef(); - constructor(props) { + constructor(props: IProps) { super(props); - this.onImageError = this.onImageError.bind(this); - this.onImageLoad = this.onImageLoad.bind(this); - this.onImageEnter = this.onImageEnter.bind(this); - this.onImageLeave = this.onImageLeave.bind(this); - this.onClientSync = this.onClientSync.bind(this); - this.onClick = this.onClick.bind(this); - this._isGif = this._isGif.bind(this); - this.state = { decryptedUrl: null, decryptedThumbnailUrl: null, @@ -72,12 +83,10 @@ export default class MImageBody extends React.Component { hover: false, showImage: SettingsStore.getValue("showImages"), }; - - this._image = createRef(); } // FIXME: factor this out and apply it to MVideoBody and MAudioBody too! - onClientSync(syncState, prevState) { + private onClientSync = (syncState: SyncState, prevState: SyncState): void => { if (this.unmounted) return; // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. @@ -88,15 +97,15 @@ export default class MImageBody extends React.Component { imgError: false, }); } - } + }; - showImage() { + protected showImage(): void { localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true"); this.setState({ showImage: true }); - this._downloadImage(); + this.downloadImage(); } - onClick(ev) { + protected onClick = (ev: React.MouseEvent): void => { if (ev.button === 0 && !ev.metaKey) { ev.preventDefault(); if (!this.state.showImage) { @@ -104,12 +113,11 @@ export default class MImageBody extends React.Component { return; } - const content = this.props.mxEvent.getContent(); - const httpUrl = this._getContentUrl(); - const ImageView = sdk.getComponent("elements.ImageView"); - const params = { + const content = this.props.mxEvent.getContent(); + const httpUrl = this.getContentUrl(); + const params: Omit, "onFinished"> = { src: httpUrl, - name: content.body && content.body.length > 0 ? content.body : _t('Attachment'), + name: content.body?.length > 0 ? content.body : _t('Attachment'), mxEvent: this.props.mxEvent, permalinkCreator: this.props.permalinkCreator, }; @@ -122,58 +130,54 @@ export default class MImageBody extends React.Component { Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); } - } + }; - _isGif() { + private isGif = (): boolean => { const content = this.props.mxEvent.getContent(); - return ( - content && - content.info && - content.info.mimetype === "image/gif" - ); - } + return content.info?.mimetype === "image/gif"; + }; - onImageEnter(e) { + private onImageEnter = (e: React.MouseEvent): void => { this.setState({ hover: true }); - if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { + if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { return; } - const imgElement = e.target; - imgElement.src = this._getContentUrl(); - } + const imgElement = e.currentTarget; + imgElement.src = this.getContentUrl(); + }; - onImageLeave(e) { + private onImageLeave = (e: React.MouseEvent): void => { this.setState({ hover: false }); - if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { + if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { return; } - const imgElement = e.target; - imgElement.src = this._getThumbUrl(); - } + const imgElement = e.currentTarget; + imgElement.src = this.getThumbUrl(); + }; - onImageError() { + private onImageError = (): void => { this.setState({ imgError: true, }); - } + }; - onImageLoad() { + private onImageLoad = (): void => { this.props.onHeightChanged(); let loadedImageDimensions; - if (this._image.current) { - const { naturalWidth, naturalHeight } = this._image.current; + if (this.image.current) { + const { naturalWidth, naturalHeight } = this.image.current; // this is only used as a fallback in case content.info.w/h is missing loadedImageDimensions = { naturalWidth, naturalHeight }; } this.setState({ imgLoaded: true, loadedImageDimensions }); - } + }; - _getContentUrl() { + protected getContentUrl(): string { const media = mediaFromContent(this.props.mxEvent.getContent()); if (media.isEncrypted) { return this.state.decryptedUrl; @@ -182,7 +186,7 @@ export default class MImageBody extends React.Component { } } - _getThumbUrl() { + protected getThumbUrl(): string { // FIXME: we let images grow as wide as you like, rather than capped to 800x600. // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // thumbnail resolution will be unnecessarily reduced. @@ -190,7 +194,7 @@ export default class MImageBody extends React.Component { const thumbWidth = 800; const thumbHeight = 600; - const content = this.props.mxEvent.getContent(); + const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); if (media.isEncrypted) { @@ -218,7 +222,7 @@ export default class MImageBody extends React.Component { // - If there's no sizing info in the event, default to thumbnail const info = content.info; if ( - this._isGif() || + this.isGif() || window.devicePixelRatio === 1.0 || (!info || !info.w || !info.h || !info.size) ) { @@ -253,7 +257,7 @@ export default class MImageBody extends React.Component { } } - _downloadImage() { + private downloadImage(): void { const content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { let thumbnailPromise = Promise.resolve(null); @@ -297,7 +301,7 @@ export default class MImageBody extends React.Component { if (showImage) { // Don't download anything becaue we don't want to display anything. - this._downloadImage(); + this.downloadImage(); this.setState({ showImage: true }); } @@ -312,7 +316,6 @@ export default class MImageBody extends React.Component { componentWillUnmount() { this.unmounted = true; this.context.removeListener('sync', this.onClientSync); - this._afterComponentWillUnmount(); if (this.state.decryptedUrl) { URL.revokeObjectURL(this.state.decryptedUrl); @@ -322,12 +325,12 @@ export default class MImageBody extends React.Component { } } - // To be overridden by subclasses (e.g. MStickerBody) for further - // cleanup after componentWillUnmount - _afterComponentWillUnmount() { - } - - _messageContent(contentUrl, thumbUrl, content) { + protected messageContent( + contentUrl: string, + thumbUrl: string, + content: IMediaEventContent, + forcedHeight?: number, + ): JSX.Element { let infoWidth; let infoHeight; @@ -348,7 +351,7 @@ export default class MImageBody extends React.Component { imageElement = ; } else { imageElement = ( - {content.body} - const maxHeight = Math.min(this.props.maxImageHeight || 600, infoHeight); + const maxHeight = forcedHeight || Math.min((this.props.maxImageHeight || 600), infoHeight); // The maximum width of the thumbnail, as dictated by its natural // maximum height. const maxWidth = infoWidth * maxHeight / infoHeight; @@ -382,7 +385,7 @@ export default class MImageBody extends React.Component { // which has the same width as the timeline // mx_MImageBody_thumbnail resizes img to exactly container size img = ( - {content.body}; + img = ; showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. } - if (this._isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { + if (this.isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { gifLabel =

GIF

; } const thumbnail = ( -
+
{ /* Calculate aspect ratio, using %padding will size _container correctly */ } -
+
{ showPlaceholder &&
{children} ; } // Overidden by MStickerBody - getPlaceholder(width, height) { + protected getPlaceholder(width: number, height: number): JSX.Element { const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD]; if (blurhash) return ; return
@@ -443,17 +446,17 @@ export default class MImageBody extends React.Component { } // Overidden by MStickerBody - getTooltip() { + protected getTooltip(): JSX.Element { return null; } // Overidden by MStickerBody - getFileBody() { + protected getFileBody(): JSX.Element { return ; } render() { - const content = this.props.mxEvent.getContent(); + const content = this.props.mxEvent.getContent(); if (this.state.error !== null) { return ( @@ -464,15 +467,15 @@ export default class MImageBody extends React.Component { ); } - const contentUrl = this._getContentUrl(); + const contentUrl = this.getContentUrl(); let thumbUrl; - if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) { + if (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) { thumbUrl = contentUrl; } else { - thumbUrl = this._getThumbUrl(); + thumbUrl = this.getThumbUrl(); } - const thumbnail = this._messageContent(contentUrl, thumbUrl, content); + const thumbnail = this.messageContent(contentUrl, thumbUrl, content); const fileBody = this.getFileBody(); return @@ -482,16 +485,18 @@ export default class MImageBody extends React.Component { } } -export class HiddenImagePlaceholder extends React.PureComponent { - static propTypes = { - hover: PropTypes.bool, - }; +interface PlaceholderIProps { + hover?: boolean; + maxWidth?: number; +} +export class HiddenImagePlaceholder extends React.PureComponent { render() { + const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null; let className = 'mx_HiddenImagePlaceholder'; if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover'; return ( -
+
{_t("Show image")} diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx new file mode 100644 index 00000000000..44acf180044 --- /dev/null +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -0,0 +1,62 @@ +/* +Copyright 2020-2021 Tulir Asokan + +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 MImageBody from "./MImageBody"; +import { presentableTextForFile } from "./MFileBody"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; +import SenderProfile from "./SenderProfile"; + +const FORCED_IMAGE_HEIGHT = 44; + +export default class MImageReplyBody extends MImageBody { + public onClick = (ev: React.MouseEvent): void => { + ev.preventDefault(); + }; + + public wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { + return children; + } + + // Don't show "Download this_file.png ..." + public getFileBody(): JSX.Element { + return presentableTextForFile(this.props.mxEvent.getContent()); + } + + render() { + if (this.state.error !== null) { + return super.render(); + } + + const content = this.props.mxEvent.getContent(); + + const contentUrl = this.getContentUrl(); + const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT); + const fileBody = this.getFileBody(); + const sender = ; + + return
+ { thumbnail } +
+
{ sender }
+
{ fileBody }
+
+
; + } +} diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 4168744d42b..cd071ebb349 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -47,6 +47,10 @@ export default class MessageEvent extends React.Component { /* the maximum image height to use, if the event is an image */ maxImageHeight: PropTypes.number, + /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */ + overrideBodyTypes: PropTypes.object, + overrideEventTypes: PropTypes.object, + /* the permalinkCreator */ permalinkCreator: PropTypes.object, }; @@ -74,9 +78,12 @@ export default class MessageEvent extends React.Component { 'm.file': sdk.getComponent('messages.MFileBody'), 'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'), 'm.video': sdk.getComponent('messages.MVideoBody'), + + ...(this.props.overrideBodyTypes || {}), }; const evTypes = { 'm.sticker': sdk.getComponent('messages.MStickerBody'), + ...(this.props.overrideEventTypes || {}), }; const content = this.props.mxEvent.getContent(); @@ -113,7 +120,7 @@ export default class MessageEvent extends React.Component { } } - return ; + /> : null; } } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 14eab5da2ed..63ac8ff3753 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -27,7 +27,6 @@ import { _t } from '../../../languageHandler'; import { hasText } from "../../../TextForEvent"; import * as sdk from "../../../index"; import dis from '../../../dispatcher/dispatcher'; -import SettingsStore from "../../../settings/SettingsStore"; import { Layout } from "../../../settings/Layout"; import { formatTime } from "../../../DateUtils"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -54,6 +53,7 @@ import TooltipButton from '../elements/TooltipButton'; import ReadReceiptMarker from "./ReadReceiptMarker"; import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from '../messages/ReactionsRow'; +import { getEventDisplayInfo } from '../../../utils/EventUtils'; const eventTileTypes = { [EventType.RoomMessage]: 'messages.MessageEvent', @@ -192,8 +192,6 @@ export interface IReadReceiptProps { export enum TileShape { Notif = "notif", FileGrid = "file_grid", - Reply = "reply", - ReplyPreview = "reply_preview", Pinned = "pinned", } @@ -848,35 +846,9 @@ export default class EventTile extends React.Component { }; render() { - //console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); - - const content = this.props.mxEvent.getContent(); - const msgtype = content.msgtype; - const eventType = this.props.mxEvent.getType(); - - let tileHandler = getHandlerTile(this.props.mxEvent); - - // Info messages are basically information about commands processed on a room - let isBubbleMessage = eventType.startsWith("m.key.verification") || - (eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) || - (eventType === EventType.RoomCreate) || - (eventType === EventType.RoomEncryption) || - (tileHandler === "messages.MJitsiWidgetEvent"); - let isInfoMessage = ( - !isBubbleMessage && eventType !== EventType.RoomMessage && - eventType !== EventType.Sticker && eventType !== EventType.RoomCreate - ); + const msgtype = this.props.mxEvent.getContent().msgtype; + const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent); - // If we're showing hidden events in the timeline, we should use the - // source tile when there's no regular tile for an event and also for - // replace relations (which otherwise would display as a confusing - // duplicate of the thing they are replacing). - if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) { - tileHandler = "messages.ViewSourceEvent"; - isBubbleMessage = false; - // Reuse info message avatar and sender profile styling - isInfoMessage = true; - } // This shouldn't happen: the caller should check we support this type // before trying to instantiate us if (!tileHandler) { @@ -980,11 +952,7 @@ export default class EventTile extends React.Component { } if (needsSenderProfile) { - if ( - !this.props.tileShape - || this.props.tileShape === TileShape.Reply - || this.props.tileShape === TileShape.ReplyPreview - ) { + if (!this.props.tileShape) { sender = { ]); } - case TileShape.Reply: - case TileShape.ReplyPreview: { - let thread; - if (this.props.tileShape === TileShape.ReplyPreview) { - thread = ReplyThread.makeThread( - this.props.mxEvent, - this.props.onHeightChanged, - this.props.permalinkCreator, - this.replyThread, - null, - this.props.alwaysShowTimestamps || this.state.hover, - ); - } - return React.createElement(this.props.as || "li", { - "className": classes, - "aria-live": ariaLive, - "aria-atomic": true, - "data-scroll-tokens": scrollToken, - }, <> - { ircTimestamp } - { avatar } - { sender } - { ircPadlock } -
- { groupTimestamp } - { groupPadlock } - { thread } - -
- ); - } default: { const thread = ReplyThread.makeThread( this.props.mxEvent, diff --git a/src/components/views/rooms/ReplyPreview.js b/src/components/views/rooms/ReplyPreview.js index e1e5a0a8462..c7d19e58dbd 100644 --- a/src/components/views/rooms/ReplyPreview.js +++ b/src/components/views/rooms/ReplyPreview.js @@ -16,15 +16,12 @@ limitations under the License. import React from 'react'; import dis from '../../../dispatcher/dispatcher'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import RoomViewStore from '../../../stores/RoomViewStore'; -import SettingsStore from "../../../settings/SettingsStore"; import PropTypes from "prop-types"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { UIFeature } from "../../../settings/UIFeature"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { TileShape } from "./EventTile"; +import ReplyTile from './ReplyTile'; function cancelQuoting() { dis.dispatch({ @@ -72,8 +69,6 @@ export default class ReplyPreview extends React.Component { render() { if (!this.state.event) return null; - const EventTile = sdk.getComponent('rooms.EventTile'); - return
@@ -89,15 +84,12 @@ export default class ReplyPreview extends React.Component { />
- +
+ +
; } diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx new file mode 100644 index 00000000000..18b30d33d5a --- /dev/null +++ b/src/components/views/rooms/ReplyTile.tsx @@ -0,0 +1,155 @@ +/* +Copyright 2020-2021 Tulir Asokan + +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 classNames from 'classnames'; +import { _t } from '../../../languageHandler'; +import dis from '../../../dispatcher/dispatcher'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; +import SenderProfile from "../messages/SenderProfile"; +import MImageReplyBody from "../messages/MImageReplyBody"; +import * as sdk from '../../../index'; +import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event'; +import { replaceableComponent } from '../../../utils/replaceableComponent'; +import { getEventDisplayInfo } from '../../../utils/EventUtils'; +import MFileBody from "../messages/MFileBody"; + +interface IProps { + mxEvent: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; + highlights?: string[]; + highlightLink?: string; + onHeightChanged?(): void; +} + +@replaceableComponent("views.rooms.ReplyTile") +export default class ReplyTile extends React.PureComponent { + static defaultProps = { + onHeightChanged: () => {}, + }; + + componentDidMount() { + this.props.mxEvent.on("Event.decrypted", this.onDecrypted); + this.props.mxEvent.on("Event.beforeRedaction", this.onEventRequiresUpdate); + this.props.mxEvent.on("Event.replaced", this.onEventRequiresUpdate); + } + + componentWillUnmount() { + this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted); + this.props.mxEvent.removeListener("Event.beforeRedaction", this.onEventRequiresUpdate); + this.props.mxEvent.removeListener("Event.replaced", this.onEventRequiresUpdate); + } + + private onDecrypted = (): void => { + this.forceUpdate(); + if (this.props.onHeightChanged) { + this.props.onHeightChanged(); + } + }; + + private onEventRequiresUpdate = (): void => { + // Force update when necessary - redactions and edits + this.forceUpdate(); + }; + + private onClick = (e: React.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 Riot when clicked. + e.preventDefault(); + dis.dispatch({ + action: 'view_room', + event_id: this.props.mxEvent.getId(), + highlighted: true, + room_id: this.props.mxEvent.getRoomId(), + }); + }; + + render() { + const mxEvent = this.props.mxEvent; + const msgType = mxEvent.getContent().msgtype; + const evType = mxEvent.getType() as EventType; + + const { tileHandler, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent); + // This shouldn't happen: the caller should check we support this type + // before trying to instantiate us + if (!tileHandler) { + const { mxEvent } = this.props; + console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`); + return
+ { _t('This event could not be displayed') } +
; + } + + const EventTileType = sdk.getComponent(tileHandler); + + const classes = classNames("mx_ReplyTile", { + mx_ReplyTile_info: isInfoMessage && !this.props.mxEvent.isRedacted(), + mx_ReplyTile_audio: msgType === MsgType.Audio, + mx_ReplyTile_video: msgType === MsgType.Video, + }); + + let permalink = "#"; + if (this.props.permalinkCreator) { + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + + let sender; + const needsSenderProfile = ( + !isInfoMessage && + msgType !== MsgType.Image && + tileHandler !== EventType.RoomCreate && + evType !== EventType.Sticker + ); + + if (needsSenderProfile) { + sender = ; + } + + const msgtypeOverrides = { + [MsgType.Image]: MImageReplyBody, + // Override audio and video body with file body. We also hide the download/decrypt button using CSS + [MsgType.Audio]: MFileBody, + [MsgType.Video]: MFileBody, + }; + const evOverrides = { + // Use MImageReplyBody so that the sticker isn't taking up a lot of space + [EventType.Sticker]: MImageReplyBody, + }; + + return ( + + ); + } +} diff --git a/src/customisations/models/IMediaEventContent.ts b/src/customisations/models/IMediaEventContent.ts index fb05d76a4d5..62dfe4ee19a 100644 --- a/src/customisations/models/IMediaEventContent.ts +++ b/src/customisations/models/IMediaEventContent.ts @@ -32,11 +32,16 @@ export interface IEncryptedFile { } export interface IMediaEventContent { + body?: string; url?: string; // required on unencrypted media file?: IEncryptedFile; // required for *encrypted* media info?: { thumbnail_url?: string; // eslint-disable-line camelcase thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase + mimetype: string; + w?: number; + h?: number; + size?: number; }; } diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 1a467b157f6..849e5464858 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -19,6 +19,9 @@ import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; import { MatrixClientPeg } from '../MatrixClientPeg'; import shouldHideEvent from "../shouldHideEvent"; +import { getHandlerTile, haveTileForEvent } from "../components/views/rooms/EventTile"; +import SettingsStore from "../settings/SettingsStore"; +import { EventType } from "matrix-js-sdk/src/@types/event"; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. @@ -96,3 +99,38 @@ export function findEditableEvent(room: Room, isForward: boolean, fromEventId: s } } +export function getEventDisplayInfo(mxEvent: MatrixEvent): { + isInfoMessage: boolean; + tileHandler: string; + isBubbleMessage: boolean; +} { + const content = mxEvent.getContent(); + const msgtype = content.msgtype; + const eventType = mxEvent.getType(); + + let tileHandler = getHandlerTile(mxEvent); + + // Info messages are basically information about commands processed on a room + let isBubbleMessage = eventType.startsWith("m.key.verification") || + (eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) || + (eventType === EventType.RoomCreate) || + (eventType === EventType.RoomEncryption) || + (tileHandler === "messages.MJitsiWidgetEvent"); + let isInfoMessage = ( + !isBubbleMessage && eventType !== EventType.RoomMessage && + eventType !== EventType.Sticker && eventType !== EventType.RoomCreate + ); + + // If we're showing hidden events in the timeline, we should use the + // source tile when there's no regular tile for an event and also for + // replace relations (which otherwise would display as a confusing + // duplicate of the thing they are replacing). + if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(mxEvent)) { + tileHandler = "messages.ViewSourceEvent"; + isBubbleMessage = false; + // Reuse info message avatar and sender profile styling + isInfoMessage = true; + } + + return { tileHandler, isInfoMessage, isBubbleMessage }; +}