Merge pull request #3553 from maunium/compact-reply-rendering

This commit is contained in:
Michael Telatynski 2021-07-15 15:52:10 +01:00 committed by GitHub
commit 376533e709
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 638 additions and 290 deletions

View file

@ -164,6 +164,7 @@
@import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss"; @import "./views/messages/_MImageBody.scss";
@import "./views/messages/_MImageReplyBody.scss";
@import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MJitsiWidgetEvent.scss";
@import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MNoticeBody.scss";
@import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MStickerBody.scss";
@ -213,6 +214,7 @@
@import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_ReplyTile.scss";
@import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss";
@import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomHeader.scss";
@import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomList.scss";

View file

@ -18,20 +18,46 @@ limitations under the License.
margin-top: 0; 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 { .mx_ReplyThread_show {
cursor: pointer; cursor: pointer;
} }
blockquote.mx_ReplyThread { blockquote.mx_ReplyThread {
margin-left: 0; margin-left: 0;
margin-right: 0;
margin-bottom: 8px;
padding-left: 10px; 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;
}
} }

View file

@ -83,12 +83,12 @@ limitations under the License.
mask-size: cover; mask-size: cover;
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
background-color: $message-body-panel-icon-fg-color; background-color: $message-body-panel-icon-fg-color;
width: 13px; width: 15px;
height: 15px; height: 15px;
position: absolute; position: absolute;
top: 8px; top: 8px;
left: 9px; left: 8px;
} }
} }

View file

@ -0,0 +1,37 @@
/*
Copyright 2020 Tulir Asokan <tulir@maunium.net>
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;
}
}
}

View file

@ -29,12 +29,16 @@ limitations under the License.
} }
.mx_ReplyPreview_header { .mx_ReplyPreview_header {
margin: 12px; margin: 8px;
color: $primary-fg-color; color: $primary-fg-color;
font-weight: 400; font-weight: 400;
opacity: 0.4; opacity: 0.4;
} }
.mx_ReplyPreview_tile {
margin: 0 8px;
}
.mx_ReplyPreview_title { .mx_ReplyPreview_title {
float: left; float: left;
} }
@ -42,6 +46,7 @@ limitations under the License.
.mx_ReplyPreview_cancel { .mx_ReplyPreview_cancel {
float: right; float: right;
cursor: pointer; cursor: pointer;
display: flex;
} }
.mx_ReplyPreview_clear { .mx_ReplyPreview_clear {

View file

@ -0,0 +1,123 @@
/*
Copyright 2020 Tulir Asokan <tulir@maunium.net>
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 <pre> 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;
}

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.97991 1.48403L4 4.80062L1 4.80062C0.447715 4.80062 0 5.24834 0 5.80062V10.2006C0 10.7529 0.447714 11.2006 0.999999 11.2006L4 11.2006L7.97991 14.5172C8.30557 14.7886 8.8 14.557 8.8 14.1331V1.86814C8.8 1.44422 8.30557 1.21265 7.97991 1.48403Z" fill="#737D8C"/>
<path d="M14.1258 2.79107C13.8998 2.50044 13.4809 2.44808 13.1903 2.67413C12.9 2.89992 12.8475 3.3181 13.0726 3.6087L13.0731 3.60935L13.0738 3.61021L13.0829 3.62231C13.0917 3.63418 13.1059 3.65355 13.1248 3.68011C13.1625 3.73326 13.2187 3.81496 13.2872 3.92256C13.4243 4.13812 13.6097 4.45554 13.7955 4.85371C14.169 5.65407 14.5329 6.75597 14.5329 8.00036C14.5329 9.24475 14.169 10.3466 13.7955 11.147C13.6097 11.5452 13.4243 11.8626 13.2872 12.0782C13.2187 12.1858 13.1625 12.2675 13.1248 12.3206C13.1059 12.3472 13.0917 12.3665 13.0829 12.3784L13.0738 12.3905L13.0731 12.3914L13.0725 12.3921C12.8475 12.6827 12.9 13.1008 13.1903 13.3266C13.4809 13.5526 13.8998 13.5003 14.1258 13.2097L13.629 12.8232C14.1258 13.2096 14.1258 13.2097 14.1258 13.2097L14.1272 13.2079L14.1291 13.2055L14.1346 13.1982L14.1523 13.1748C14.1669 13.1552 14.187 13.1277 14.2119 13.0926C14.2617 13.0225 14.3305 12.9221 14.4121 12.794C14.5749 12.5381 14.7895 12.1698 15.0037 11.7109C15.4302 10.7969 15.8663 9.49883 15.8663 8.00036C15.8663 6.50189 15.4302 5.20379 15.0037 4.28987C14.7895 3.83089 14.5749 3.4626 14.4121 3.20673C14.3305 3.07862 14.2617 2.97818 14.2119 2.90811C14.187 2.87306 14.1669 2.84556 14.1523 2.82596L14.1346 2.80249L14.1291 2.79525L14.1272 2.79278L14.1264 2.79183C14.1264 2.79183 14.1258 2.79107 13.5996 3.20036L14.1258 2.79107Z" fill="#737D8C"/>
<path d="M11.7264 5.19121C11.5004 4.90058 11.0815 4.84823 10.7909 5.07427C10.501 5.29973 10.4482 5.71698 10.6722 6.00752L10.6745 6.01057C10.6775 6.01457 10.6831 6.02223 10.691 6.03338C10.7069 6.05572 10.7318 6.09189 10.7628 6.14057C10.8249 6.23827 10.9103 6.38426 10.9961 6.56815C11.1696 6.93993 11.3335 7.44183 11.3335 8.00051C11.3335 8.55918 11.1696 9.06108 10.9961 9.43287C10.9103 9.61675 10.8249 9.76275 10.7628 9.86045C10.7318 9.90912 10.7069 9.94529 10.691 9.96763C10.6831 9.97879 10.6775 9.98645 10.6745 9.99044L10.6722 9.9935C10.4482 10.284 10.501 10.7013 10.7909 10.9267C11.0815 11.1528 11.5004 11.1004 11.7264 10.8098L11.2002 10.4005C11.7264 10.8098 11.7264 10.8098 11.7264 10.8098L11.7276 10.8083L11.7291 10.8064L11.7329 10.8014L11.7439 10.7868C11.7526 10.7751 11.7642 10.7593 11.7781 10.7396C11.806 10.7004 11.8436 10.6455 11.8876 10.5763C11.9755 10.4383 12.0901 10.2414 12.2043 9.99672C12.4308 9.51136 12.6669 8.81326 12.6669 8.00051C12.6669 7.18775 12.4308 6.48965 12.2043 6.0043C12.0901 5.75961 11.9755 5.56275 11.8876 5.42473C11.8436 5.35555 11.806 5.30065 11.7781 5.26138C11.7642 5.24173 11.7526 5.22596 11.7439 5.21422L11.7329 5.19964L11.7291 5.19465L11.7276 5.19274L11.727 5.19193C11.727 5.19193 11.7264 5.19121 11.2002 5.60051L11.7264 5.19121Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -33,6 +33,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { normalizeWheelEvent } from "../../../utils/Mouse"; import { normalizeWheelEvent } from "../../../utils/Mouse";
import { IDialogProps } from '../dialogs/IDialogProps';
// Max scale to keep gaps around the image // Max scale to keep gaps around the image
const MAX_SCALE = 0.95; const MAX_SCALE = 0.95;
@ -43,14 +44,13 @@ const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom // If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10; const ZOOM_DISTANCE = 10;
interface IProps { interface IProps extends IDialogProps {
src: string; // the source of the image being displayed src: string; // the source of the image being displayed
name?: string; // the main title ('name') for the image name?: string; // the main title ('name') for the image
link?: string; // the link (if any) applied to the name of the image link?: string; // the link (if any) applied to the name of the image
width?: number; // width of the image src in pixels width?: number; // width of the image src in pixels
height?: number; // height of the image src in pixels height?: number; // height of the image src in pixels
fileSize?: number; // size of the image src in bytes 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 // 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 // redactions, senders, timestamps etc. Other descriptors are taken from the explicit

View file

@ -15,23 +15,23 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { wantsDateSeparator } from '../../../DateUtils';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { LayoutPropType } from "../../../settings/Layout"; import { LayoutPropType } from "../../../settings/Layout";
import escapeHtml from "escape-html"; import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { UIFeature } from "../../../settings/UIFeature";
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils"; import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent"; 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 // 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 // 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.unmounted = false;
this.context.on("Event.replaced", this.onEventReplaced);
this.room = this.context.getRoom(this.props.parentEv.getRoomId()); 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.onQuoteClick = this.onQuoteClick.bind(this);
this.canCollapse = this.canCollapse.bind(this); this.canCollapse = this.canCollapse.bind(this);
@ -238,36 +235,8 @@ export default class ReplyThread extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; 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() { async initialize() {
const { parentEv } = this.props; const { parentEv } = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId // 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); dis.fire(Action.FocusSendMessageComposer);
} }
getReplyThreadColorClass(ev) {
return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread");
}
render() { render() {
let header = null; let header = null;
@ -349,9 +322,8 @@ export default class ReplyThread extends React.Component {
</blockquote>; </blockquote>;
} else if (this.state.loadedEv) { } else if (this.state.loadedEv) {
const ev = this.state.loadedEv; const ev = this.state.loadedEv;
const Pill = sdk.getComponent('elements.Pill');
const room = this.context.getRoom(ev.getRoomId()); const room = this.context.getRoom(ev.getRoomId());
header = <blockquote className="mx_ReplyThread"> header = <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`}>
{ {
_t('<a>In reply to</a> <pill>', {}, { _t('<a>In reply to</a> <pill>', {}, {
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>, 'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
@ -367,33 +339,15 @@ export default class ReplyThread extends React.Component {
} }
</blockquote>; </blockquote>;
} else if (this.state.loading) { } else if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
header = <Spinner w={16} h={16} />; header = <Spinner w={16} h={16} />;
} }
const EventTile = sdk.getComponent('views.rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const evTiles = this.state.events.map((ev) => { const evTiles = this.state.events.map((ev) => {
let dateSep = null; return <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`} key={ev.getId()}>
<ReplyTile
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
}
return <blockquote className="mx_ReplyThread" key={ev.getId()}>
{ dateSep }
<EventTile
mxEvent={ev} mxEvent={ev}
tileShape={TileShape.Reply}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
as="div"
/> />
</blockquote>; </blockquote>;
}); });

View file

@ -90,6 +90,35 @@ function computedStyle(element) {
return cssText; 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") @replaceableComponent("views.messages.MFileBody")
export default class MFileBody extends React.Component { export default class MFileBody extends React.Component {
static propTypes = { static propTypes = {
@ -120,35 +149,6 @@ export default class MFileBody extends React.Component {
this._dummyLink = createRef(); 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() { _getContentUrl() {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp; return media.srcHttp;
@ -162,7 +162,7 @@ export default class MFileBody extends React.Component {
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content); const text = presentableTextForFile(content);
const isEncrypted = content.file !== undefined; const isEncrypted = content.file !== undefined;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this._getContentUrl(); const contentUrl = this._getContentUrl();
@ -174,7 +174,9 @@ export default class MFileBody extends React.Component {
placeholder = ( placeholder = (
<div className="mx_MFileBody_info"> <div className="mx_MFileBody_info">
<span className="mx_MFileBody_info_icon" /> <span className="mx_MFileBody_info_icon" />
<span className="mx_MFileBody_info_filename">{this.presentableTextForFile(content, false)}</span> <span className="mx_MFileBody_info_filename">
{ presentableTextForFile(content, false) }
</span>
</div> </div>
); );
} }

View file

@ -16,13 +16,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { ComponentProps, createRef } from 'react';
import PropTypes from 'prop-types';
import { Blurhash } from "react-blurhash"; import { Blurhash } from "react-blurhash";
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as sdk from '../../../index';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -31,36 +29,49 @@ import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages"; 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;
}
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") @replaceableComponent("views.messages.MImageBody")
export default class MImageBody extends React.Component { export default class MImageBody extends React.Component<IProps, IState> {
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,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted = true;
private image = createRef<HTMLImageElement>();
constructor(props) { constructor(props: IProps) {
super(props); 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 = { this.state = {
decryptedUrl: null, decryptedUrl: null,
decryptedThumbnailUrl: null, decryptedThumbnailUrl: null,
@ -72,12 +83,10 @@ export default class MImageBody extends React.Component {
hover: false, hover: false,
showImage: SettingsStore.getValue("showImages"), showImage: SettingsStore.getValue("showImages"),
}; };
this._image = createRef();
} }
// FIXME: factor this out and apply it to MVideoBody and MAudioBody too! // 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; if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing. // Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
@ -88,15 +97,15 @@ export default class MImageBody extends React.Component {
imgError: false, imgError: false,
}); });
} }
} };
showImage() { protected showImage(): void {
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true"); localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
this.setState({ showImage: true }); this.setState({ showImage: true });
this._downloadImage(); this.downloadImage();
} }
onClick(ev) { protected onClick = (ev: React.MouseEvent): void => {
if (ev.button === 0 && !ev.metaKey) { if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault(); ev.preventDefault();
if (!this.state.showImage) { if (!this.state.showImage) {
@ -104,12 +113,11 @@ export default class MImageBody extends React.Component {
return; return;
} }
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const httpUrl = this._getContentUrl(); const httpUrl = this.getContentUrl();
const ImageView = sdk.getComponent("elements.ImageView"); const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
const params = {
src: httpUrl, 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, mxEvent: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
}; };
@ -122,58 +130,54 @@ export default class MImageBody extends React.Component {
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
} }
} };
_isGif() { private isGif = (): boolean => {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
return ( return content.info?.mimetype === "image/gif";
content && };
content.info &&
content.info.mimetype === "image/gif"
);
}
onImageEnter(e) { private onImageEnter = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: true }); this.setState({ hover: true });
if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return; return;
} }
const imgElement = e.target; const imgElement = e.currentTarget;
imgElement.src = this._getContentUrl(); imgElement.src = this.getContentUrl();
} };
onImageLeave(e) { private onImageLeave = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: false }); this.setState({ hover: false });
if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return; return;
} }
const imgElement = e.target; const imgElement = e.currentTarget;
imgElement.src = this._getThumbUrl(); imgElement.src = this.getThumbUrl();
} };
onImageError() { private onImageError = (): void => {
this.setState({ this.setState({
imgError: true, imgError: true,
}); });
} };
onImageLoad() { private onImageLoad = (): void => {
this.props.onHeightChanged(); this.props.onHeightChanged();
let loadedImageDimensions; let loadedImageDimensions;
if (this._image.current) { if (this.image.current) {
const { naturalWidth, naturalHeight } = this._image.current; const { naturalWidth, naturalHeight } = this.image.current;
// this is only used as a fallback in case content.info.w/h is missing // this is only used as a fallback in case content.info.w/h is missing
loadedImageDimensions = { naturalWidth, naturalHeight }; loadedImageDimensions = { naturalWidth, naturalHeight };
} }
this.setState({ imgLoaded: true, loadedImageDimensions }); this.setState({ imgLoaded: true, loadedImageDimensions });
} };
_getContentUrl() { protected getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
if (media.isEncrypted) { if (media.isEncrypted) {
return this.state.decryptedUrl; 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. // 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 // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced. // thumbnail resolution will be unnecessarily reduced.
@ -190,7 +194,7 @@ export default class MImageBody extends React.Component {
const thumbWidth = 800; const thumbWidth = 800;
const thumbHeight = 600; const thumbHeight = 600;
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (media.isEncrypted) { 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 // - If there's no sizing info in the event, default to thumbnail
const info = content.info; const info = content.info;
if ( if (
this._isGif() || this.isGif() ||
window.devicePixelRatio === 1.0 || window.devicePixelRatio === 1.0 ||
(!info || !info.w || !info.h || !info.size) (!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(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null); let thumbnailPromise = Promise.resolve(null);
@ -297,7 +301,7 @@ export default class MImageBody extends React.Component {
if (showImage) { if (showImage) {
// Don't download anything becaue we don't want to display anything. // Don't download anything becaue we don't want to display anything.
this._downloadImage(); this.downloadImage();
this.setState({ showImage: true }); this.setState({ showImage: true });
} }
@ -312,7 +316,6 @@ export default class MImageBody extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.removeListener('sync', this.onClientSync); this.context.removeListener('sync', this.onClientSync);
this._afterComponentWillUnmount();
if (this.state.decryptedUrl) { if (this.state.decryptedUrl) {
URL.revokeObjectURL(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 protected messageContent(
// cleanup after componentWillUnmount contentUrl: string,
_afterComponentWillUnmount() { thumbUrl: string,
} content: IMediaEventContent,
forcedHeight?: number,
_messageContent(contentUrl, thumbUrl, content) { ): JSX.Element {
let infoWidth; let infoWidth;
let infoHeight; let infoHeight;
@ -348,7 +351,7 @@ export default class MImageBody extends React.Component {
imageElement = <HiddenImagePlaceholder />; imageElement = <HiddenImagePlaceholder />;
} else { } else {
imageElement = ( imageElement = (
<img style={{ display: 'none' }} src={thumbUrl} ref={this._image} <img style={{ display: 'none' }} src={thumbUrl} ref={this.image}
alt={content.body} alt={content.body}
onError={this.onImageError} onError={this.onImageError}
onLoad={this.onImageLoad} onLoad={this.onImageLoad}
@ -362,7 +365,7 @@ export default class MImageBody extends React.Component {
} }
// The maximum height of the thumbnail as it is rendered as an <img> // The maximum height of the thumbnail as it is rendered as an <img>
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 // The maximum width of the thumbnail, as dictated by its natural
// maximum height. // maximum height.
const maxWidth = infoWidth * maxHeight / infoHeight; const maxWidth = infoWidth * maxHeight / infoHeight;
@ -382,7 +385,7 @@ export default class MImageBody extends React.Component {
// which has the same width as the timeline // which has the same width as the timeline
// mx_MImageBody_thumbnail resizes img to exactly container size // mx_MImageBody_thumbnail resizes img to exactly container size
img = ( img = (
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this._image} <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this.image}
style={{ maxWidth: maxWidth + "px" }} style={{ maxWidth: maxWidth + "px" }}
alt={content.body} alt={content.body}
onError={this.onImageError} onError={this.onImageError}
@ -393,18 +396,18 @@ export default class MImageBody extends React.Component {
} }
if (!this.state.showImage) { if (!this.state.showImage) {
img = <HiddenImagePlaceholder style={{ maxWidth: maxWidth + "px" }} />; img = <HiddenImagePlaceholder maxWidth={maxWidth} />;
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. 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 = <p className="mx_MImageBody_gifLabel">GIF</p>; gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
} }
const thumbnail = ( const thumbnail = (
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px" }} > <div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }} >
{ /* Calculate aspect ratio, using %padding will size _container correctly */ } { /* Calculate aspect ratio, using %padding will size _container correctly */ }
<div style={{ paddingBottom: (100 * infoHeight / infoWidth) + '%' }} /> <div style={{ paddingBottom: forcedHeight ? (forcedHeight + "px") : ((100 * infoHeight / infoWidth) + '%') }} />
{ showPlaceholder && { showPlaceholder &&
<div className="mx_MImageBody_thumbnail" style={{ <div className="mx_MImageBody_thumbnail" style={{
// Constrain width here so that spinner appears central to the loaded thumbnail // Constrain width here so that spinner appears central to the loaded thumbnail
@ -427,14 +430,14 @@ export default class MImageBody extends React.Component {
} }
// Overidden by MStickerBody // Overidden by MStickerBody
wrapImage(contentUrl, children) { protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
return <a href={contentUrl} onClick={this.onClick}> return <a href={contentUrl} onClick={this.onClick}>
{children} {children}
</a>; </a>;
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getPlaceholder(width, height) { protected getPlaceholder(width: number, height: number): JSX.Element {
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD]; const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />; if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
return <div className="mx_MImageBody_thumbnail_spinner"> return <div className="mx_MImageBody_thumbnail_spinner">
@ -443,17 +446,17 @@ export default class MImageBody extends React.Component {
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getTooltip() { protected getTooltip(): JSX.Element {
return null; return null;
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getFileBody() { protected getFileBody(): JSX.Element {
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />; return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
} }
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
if (this.state.error !== null) { if (this.state.error !== null) {
return ( return (
@ -464,15 +467,15 @@ export default class MImageBody extends React.Component {
); );
} }
const contentUrl = this._getContentUrl(); const contentUrl = this.getContentUrl();
let thumbUrl; let thumbUrl;
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) { if (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
thumbUrl = contentUrl; thumbUrl = contentUrl;
} else { } 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(); const fileBody = this.getFileBody();
return <span className="mx_MImageBody"> return <span className="mx_MImageBody">
@ -482,16 +485,18 @@ export default class MImageBody extends React.Component {
} }
} }
export class HiddenImagePlaceholder extends React.PureComponent { interface PlaceholderIProps {
static propTypes = { hover?: boolean;
hover: PropTypes.bool, maxWidth?: number;
}; }
export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProps> {
render() { render() {
const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null;
let className = 'mx_HiddenImagePlaceholder'; let className = 'mx_HiddenImagePlaceholder';
if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover'; if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover';
return ( return (
<div className={className}> <div className={className} style={{ maxWidth: maxWidth }}>
<div className='mx_HiddenImagePlaceholder_button'> <div className='mx_HiddenImagePlaceholder_button'>
<span className='mx_HiddenImagePlaceholder_eye' /> <span className='mx_HiddenImagePlaceholder_eye' />
<span>{_t("Show image")}</span> <span>{_t("Show image")}</span>

View file

@ -0,0 +1,62 @@
/*
Copyright 2020-2021 Tulir Asokan <tulir@maunium.net>
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<IMediaEventContent>();
const contentUrl = this.getContentUrl();
const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT);
const fileBody = this.getFileBody();
const sender = <SenderProfile
mxEvent={this.props.mxEvent}
enableFlair={false}
/>;
return <div className="mx_MImageReplyBody">
{ thumbnail }
<div className="mx_MImageReplyBody_info">
<div className="mx_MImageReplyBody_sender">{ sender }</div>
<div className="mx_MImageReplyBody_filename">{ fileBody }</div>
</div>
</div>;
}
}

View file

@ -47,6 +47,10 @@ export default class MessageEvent extends React.Component {
/* the maximum image height to use, if the event is an image */ /* the maximum image height to use, if the event is an image */
maxImageHeight: PropTypes.number, maxImageHeight: PropTypes.number,
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes: PropTypes.object,
overrideEventTypes: PropTypes.object,
/* the permalinkCreator */ /* the permalinkCreator */
permalinkCreator: PropTypes.object, permalinkCreator: PropTypes.object,
}; };
@ -74,9 +78,12 @@ export default class MessageEvent extends React.Component {
'm.file': sdk.getComponent('messages.MFileBody'), 'm.file': sdk.getComponent('messages.MFileBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'), 'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'), 'm.video': sdk.getComponent('messages.MVideoBody'),
...(this.props.overrideBodyTypes || {}),
}; };
const evTypes = { const evTypes = {
'm.sticker': sdk.getComponent('messages.MStickerBody'), 'm.sticker': sdk.getComponent('messages.MStickerBody'),
...(this.props.overrideEventTypes || {}),
}; };
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
@ -113,7 +120,7 @@ export default class MessageEvent extends React.Component {
} }
} }
return <BodyType return BodyType ? <BodyType
ref={this._body} ref={this._body}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
@ -126,6 +133,6 @@ export default class MessageEvent extends React.Component {
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
onMessageAllowed={this.onTileUpdate} onMessageAllowed={this.onTileUpdate}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
/>; /> : null;
} }
} }

View file

@ -27,7 +27,6 @@ import { _t } from '../../../languageHandler';
import { hasText } from "../../../TextForEvent"; import { hasText } from "../../../TextForEvent";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout"; import { Layout } from "../../../settings/Layout";
import { formatTime } from "../../../DateUtils"; import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -54,6 +53,7 @@ import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker from "./ReadReceiptMarker"; import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar"; import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow'; import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
const eventTileTypes = { const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent', [EventType.RoomMessage]: 'messages.MessageEvent',
@ -192,8 +192,6 @@ export interface IReadReceiptProps {
export enum TileShape { export enum TileShape {
Notif = "notif", Notif = "notif",
FileGrid = "file_grid", FileGrid = "file_grid",
Reply = "reply",
ReplyPreview = "reply_preview",
Pinned = "pinned", Pinned = "pinned",
} }
@ -848,35 +846,9 @@ export default class EventTile extends React.Component<IProps, IState> {
}; };
render() { render() {
//console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); const msgtype = this.props.mxEvent.getContent().msgtype;
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
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
);
// 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 // This shouldn't happen: the caller should check we support this type
// before trying to instantiate us // before trying to instantiate us
if (!tileHandler) { if (!tileHandler) {
@ -980,11 +952,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
if (needsSenderProfile) { if (needsSenderProfile) {
if ( if (!this.props.tileShape) {
!this.props.tileShape
|| this.props.tileShape === TileShape.Reply
|| this.props.tileShape === TileShape.ReplyPreview
) {
sender = <SenderProfile onClick={this.onSenderProfileClick} sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}
@ -1134,44 +1102,6 @@ export default class EventTile extends React.Component<IProps, IState> {
]); ]);
} }
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 }
<div className="mx_EventTile_reply" key="mx_EventTile_reply">
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false}
/>
</div>
</>);
}
default: { default: {
const thread = ReplyThread.makeThread( const thread = ReplyThread.makeThread(
this.props.mxEvent, this.props.mxEvent,

View file

@ -16,15 +16,12 @@ limitations under the License.
import React from 'react'; import React from 'react';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "./EventTile"; import ReplyTile from './ReplyTile';
function cancelQuoting() { function cancelQuoting() {
dis.dispatch({ dis.dispatch({
@ -72,8 +69,6 @@ export default class ReplyPreview extends React.Component {
render() { render() {
if (!this.state.event) return null; if (!this.state.event) return null;
const EventTile = sdk.getComponent('rooms.EventTile');
return <div className="mx_ReplyPreview"> return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section"> <div className="mx_ReplyPreview_section">
<div className="mx_ReplyPreview_header mx_ReplyPreview_title"> <div className="mx_ReplyPreview_header mx_ReplyPreview_title">
@ -89,15 +84,12 @@ export default class ReplyPreview extends React.Component {
/> />
</div> </div>
<div className="mx_ReplyPreview_clear" /> <div className="mx_ReplyPreview_clear" />
<EventTile <div className="mx_ReplyPreview_tile">
alwaysShowTimestamps={true} <ReplyTile
tileShape={TileShape.ReplyPreview} mxEvent={this.state.event}
mxEvent={this.state.event} permalinkCreator={this.props.permalinkCreator}
permalinkCreator={this.props.permalinkCreator} />
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} </div>
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
as="div"
/>
</div> </div>
</div>; </div>;
} }

View file

@ -0,0 +1,155 @@
/*
Copyright 2020-2021 Tulir Asokan <tulir@maunium.net>
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<IProps> {
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 <div className="mx_ReplyTile mx_ReplyTile_info mx_MNoticeBody">
{ _t('This event could not be displayed') }
</div>;
}
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 = <SenderProfile
mxEvent={this.props.mxEvent}
enableFlair={false}
/>;
}
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 (
<div className={classes}>
<a href={permalink} onClick={this.onClick}>
{ sender }
<EventTileType
ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
showUrlPreview={false}
overrideBodyTypes={msgtypeOverrides}
overrideEventTypes={evOverrides}
replacingEventId={this.props.mxEvent.replacingEventId()}
maxImageHeight={96} />
</a>
</div>
);
}
}

View file

@ -32,11 +32,16 @@ export interface IEncryptedFile {
} }
export interface IMediaEventContent { export interface IMediaEventContent {
body?: string;
url?: string; // required on unencrypted media url?: string; // required on unencrypted media
file?: IEncryptedFile; // required for *encrypted* media file?: IEncryptedFile; // required for *encrypted* media
info?: { info?: {
thumbnail_url?: string; // eslint-disable-line camelcase thumbnail_url?: string; // eslint-disable-line camelcase
thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase
mimetype: string;
w?: number;
h?: number;
size?: number;
}; };
} }

View file

@ -19,6 +19,9 @@ import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { MatrixClientPeg } from '../MatrixClientPeg'; import { MatrixClientPeg } from '../MatrixClientPeg';
import shouldHideEvent from "../shouldHideEvent"; 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. * 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 };
}