From d282675bc643058c4835db499e68d7b34af0e13f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 13 Oct 2019 15:08:50 +0300 Subject: [PATCH] Improve reply rendering Signed-off-by: Tulir Asokan --- res/css/_components.scss | 1 + res/css/views/elements/_ReplyThread.scss | 11 +- res/css/views/rooms/_ReplyPreview.scss | 7 +- res/css/views/rooms/_ReplyTile.scss | 96 +++++++ src/components/views/elements/ReplyThread.js | 15 +- .../views/messages/MImageReplyBody.js | 33 +++ src/components/views/messages/MessageEvent.js | 9 +- src/components/views/rooms/ReplyPreview.js | 12 +- src/components/views/rooms/ReplyTile.js | 234 ++++++++++++++++++ 9 files changed, 388 insertions(+), 30 deletions(-) create mode 100644 res/css/views/rooms/_ReplyTile.scss create mode 100644 src/components/views/messages/MImageReplyBody.js create mode 100644 src/components/views/rooms/ReplyTile.js diff --git a/res/css/_components.scss b/res/css/_components.scss index 4891fd90c0..2c54c5f37f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -153,6 +153,7 @@ @import "./views/rooms/_PinnedEventsPanel.scss"; @import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_ReplyPreview.scss"; +@import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomDropTarget.scss"; @import "./views/rooms/_RoomHeader.scss"; diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index bf44a11728..0d53a6b6f4 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -18,20 +18,13 @@ 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-bottom: 8px; padding-left: 10px; - border-left: 4px solid $blockquote-bar-color; + border-left: 4px solid $button-bg-color; } diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index 4dc4cb2c40..08fbd27808 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -32,12 +32,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; } @@ -45,6 +49,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 0000000000..0a055297c6 --- /dev/null +++ b/res/css/views/rooms/_ReplyTile.scss @@ -0,0 +1,96 @@ +/* +Copyright 2019 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 { + max-width: 100%; + clear: both; + padding-top: 2px; + padding-bottom: 2px; + font-size: 14px; + position: relative; + line-height: 16px; +} + +.mx_ReplyTile > a { + display: block; + text-decoration: none; + color: $primary-fg-color; +} + +// We do reply size limiting with CSS to avoid duplicating the TextualBody component. +.mx_ReplyTile .mx_EventTile_content { + $reply-lines: 2; + $line-height: 22px; + + 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: 14px !important; + } +} + +.mx_ReplyTile.mx_ReplyTile_info { + padding-top: 0px; +} + +.mx_ReplyTile .mx_SenderProfile { + color: $primary-fg-color; + font-size: 14px; + display: inline-block; /* anti-zalgo, with overflow hidden */ + overflow: hidden; + cursor: pointer; + padding-left: 0px; /* left gutter */ + padding-bottom: 0px; + padding-top: 0px; + margin: 0px; + line-height: 17px; + /* the next three lines, along with overflow hidden, truncate long display names */ + white-space: nowrap; + text-overflow: ellipsis; + max-width: calc(100% - 65px); +} + +.mx_ReplyTile_redacted .mx_UnknownBody { + --lozenge-color: $event-redacted-fg-color; + --lozenge-border-color: $event-redacted-border-color; + display: block; + height: 22px; + width: 250px; + border-radius: 11px; + background: + repeating-linear-gradient( + -45deg, + var(--lozenge-color), + var(--lozenge-color) 3px, + transparent 3px, + transparent 6px + ); + box-shadow: 0px 0px 3px var(--lozenge-border-color) inset; +} + +.mx_ReplyTile_sending.mx_ReplyTile_redacted .mx_UnknownBody { + opacity: 0.4; +} + +.mx_ReplyTile_contextual { + opacity: 0.4; +} diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index fac0a71617..1764c008fa 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -304,20 +304,11 @@ export default class ReplyThread extends React.Component { header = ; } - const EventTile = sdk.getComponent('views.rooms.EventTile'); - const DateSeparator = sdk.getComponent('messages.DateSeparator'); + const ReplyTile = sdk.getComponent('views.rooms.ReplyTile'); const evTiles = this.state.events.map((ev) => { - let dateSep = null; - - if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) { - dateSep = ; - } - return
- { dateSep } - ; }); - return
+ return
{ header }
{ evTiles }
; diff --git a/src/components/views/messages/MImageReplyBody.js b/src/components/views/messages/MImageReplyBody.js new file mode 100644 index 0000000000..bb869919fc --- /dev/null +++ b/src/components/views/messages/MImageReplyBody.js @@ -0,0 +1,33 @@ +/* +Copyright 2019 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'; + +export default class MImageReplyBody extends MImageBody { + onClick(ev) { + ev.preventDefault(); + } + + wrapImage(contentUrl, children) { + return children; + } + + // Don't show "Download this_file.png ..." + getFileBody() { + return null; + } +} diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index a616dd96ed..28f2a471bb 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -43,6 +43,9 @@ module.exports = createReactClass({ /* the maximum image height to use, if the event is an image */ maxImageHeight: PropTypes.number, + + overrideBodyTypes: PropTypes.object, + overrideEventTypes: PropTypes.object, }, getEventTileOps: function() { @@ -60,9 +63,11 @@ module.exports = createReactClass({ 'm.file': sdk.getComponent('messages.MFileBody'), 'm.audio': sdk.getComponent('messages.MAudioBody'), '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(); @@ -81,7 +86,7 @@ module.exports = createReactClass({ } } - return ; + onHeightChanged={this.props.onHeightChanged} /> : null; }, }); diff --git a/src/components/views/rooms/ReplyPreview.js b/src/components/views/rooms/ReplyPreview.js index caf8feeea2..a69a286a15 100644 --- a/src/components/views/rooms/ReplyPreview.js +++ b/src/components/views/rooms/ReplyPreview.js @@ -68,7 +68,7 @@ export default class ReplyPreview extends React.Component { render() { if (!this.state.event) return null; - const EventTile = sdk.getComponent('rooms.EventTile'); + const ReplyTile = sdk.getComponent('rooms.ReplyTile'); return
@@ -80,11 +80,11 @@ export default class ReplyPreview extends React.Component { onClick={cancelQuoting} />
- +
+ +
; } diff --git a/src/components/views/rooms/ReplyTile.js b/src/components/views/rooms/ReplyTile.js new file mode 100644 index 0000000000..5a56ba9dc1 --- /dev/null +++ b/src/components/views/rooms/ReplyTile.js @@ -0,0 +1,234 @@ +/* +Copyright 2019 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 PropTypes from 'prop-types'; +const classNames = require("classnames"); +import { _t, _td } from '../../../languageHandler'; + +const sdk = require('../../../index'); + +import dis from '../../../dispatcher'; +import SettingsStore from "../../../settings/SettingsStore"; +import {MatrixClient} from 'matrix-js-sdk'; + +const ObjectUtils = require('../../../ObjectUtils'); + +const eventTileTypes = { + 'm.room.message': 'messages.MessageEvent', + 'm.sticker': 'messages.MessageEvent', + 'm.call.invite': 'messages.TextualEvent', + 'm.call.answer': 'messages.TextualEvent', + 'm.call.hangup': 'messages.TextualEvent', +}; + +const stateEventTileTypes = { + 'm.room.aliases': 'messages.TextualEvent', + // 'm.room.aliases': 'messages.RoomAliasesEvent', // too complex + 'm.room.canonical_alias': 'messages.TextualEvent', + 'm.room.create': 'messages.RoomCreate', + 'm.room.member': 'messages.TextualEvent', + 'm.room.name': 'messages.TextualEvent', + 'm.room.avatar': 'messages.RoomAvatarEvent', + 'm.room.third_party_invite': 'messages.TextualEvent', + 'm.room.history_visibility': 'messages.TextualEvent', + 'm.room.encryption': 'messages.TextualEvent', + 'm.room.topic': 'messages.TextualEvent', + 'm.room.power_levels': 'messages.TextualEvent', + 'm.room.pinned_events': 'messages.TextualEvent', + 'm.room.server_acl': 'messages.TextualEvent', + 'im.vector.modular.widgets': 'messages.TextualEvent', + 'm.room.tombstone': 'messages.TextualEvent', + 'm.room.join_rules': 'messages.TextualEvent', + 'm.room.guest_access': 'messages.TextualEvent', + 'm.room.related_groups': 'messages.TextualEvent', +}; + +function getHandlerTile(ev) { + const type = ev.getType(); + return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type]; +} + +class ReplyTile extends React.Component { + static contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + } + + static propTypes = { + mxEvent: PropTypes.object.isRequired, + isRedacted: PropTypes.bool, + permalinkCreator: PropTypes.object, + onHeightChanged: PropTypes.func, + } + + static defaultProps = { + onHeightChanged: function() {}, + } + + constructor(props, context) { + super(props, context); + this.state = {}; + this.onClick = this.onClick.bind(this); + } + + componentDidMount() { + this.props.mxEvent.on("Event.decrypted", this._onDecrypted); + } + + shouldComponentUpdate(nextProps, nextState) { + if (!ObjectUtils.shallowEqual(this.state, nextState)) { + return true; + } + + return !this._propsEqual(this.props, nextProps); + } + + componentWillUnmount() { + const client = this.context.matrixClient; + this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted); + } + + _onDecrypted() { + this.forceUpdate(); + } + + _propsEqual(objA, objB) { + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + for (let i = 0; i < keysA.length; i++) { + const key = keysA[i]; + + if (!objB.hasOwnProperty(key)) { + return false; + } + + if (objA[key] !== objB[key]) { + return false; + } + } + return true; + } + + onClick(e) { + // 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 SenderProfile = sdk.getComponent('messages.SenderProfile'); + + const content = this.props.mxEvent.getContent(); + const msgtype = content.msgtype; + const eventType = this.props.mxEvent.getType(); + + // Info messages are basically information about commands processed on a room + let isInfoMessage = ( + eventType !== 'm.room.message' && eventType !== 'm.sticker' && eventType !== 'm.room.create' + ); + + let tileHandler = getHandlerTile(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). + const useSource = !tileHandler || this.props.mxEvent.isRelation("m.replace"); + if (useSource && SettingsStore.getValue("showHiddenEventsInTimeline")) { + tileHandler = "messages.ViewSourceEvent"; + // 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) { + 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: true, + mx_ReplyTile_info: isInfoMessage, + mx_ReplyTile_redacted: this.props.isRedacted, + }); + + let permalink = "#"; + if (this.props.permalinkCreator) { + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + + let sender; + let needsSenderProfile = tileHandler !== 'messages.RoomCreate' && !isInfoMessage; + + if (needsSenderProfile) { + let text = null; + if (msgtype === 'm.image') text = _td('%(senderName)s sent an image'); + else if (msgtype === 'm.video') text = _td('%(senderName)s sent a video'); + else if (msgtype === 'm.file') text = _td('%(senderName)s uploaded a file'); + sender = ; + } + + const MImageReplyBody = sdk.getComponent('messages.MImageReplyBody'); + const TextualBody = sdk.getComponent('messages.TextualBody'); + const msgtypeOverrides = { + "m.image": MImageReplyBody, + // We don't want a download link for files, just the file name is enough. + "m.file": TextualBody, + "m.sticker": TextualBody, + "m.audio": TextualBody, + "m.video": TextualBody, + }; + const evOverrides = { + "m.sticker": TextualBody, + }; + + return ( + + ) + } +} + +module.exports = ReplyTile;