diff --git a/src/ImageUtils.js b/src/ImageUtils.js new file mode 100644 index 0000000000..fdb12c7608 --- /dev/null +++ b/src/ImageUtils.js @@ -0,0 +1,57 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +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. +*/ + +'use strict'; + +module.exports = { + + /** + * Returns the actual height that an image of dimensions (fullWidth, fullHeight) + * will occupy if resized to fit inside a thumbnail bounding box of size + * (thumbWidth, thumbHeight). + * + * If the aspect ratio of the source image is taller than the aspect ratio of + * the thumbnail bounding box, then we return the thumbHeight parameter unchanged. + * Otherwise we return the thumbHeight parameter scaled down appropriately to + * reflect the actual height the scaled thumbnail occupies. + * + * This is very useful for calculating how much height a thumbnail will actually + * consume in the timeline, when performing scroll offset calcuations + * (e.g. scroll locking) + */ + thumbHeight: function(fullWidth, fullHeight, thumbWidth, thumbHeight) { + if (!fullWidth || !fullHeight) { + // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even + // log this because it's spammy + return undefined; + } + if (fullWidth < thumbWidth && fullHeight < thumbHeight) { + // no scaling needs to be applied + return fullHeight; + } + var widthMulti = thumbWidth / fullWidth; + var heightMulti = thumbHeight / fullHeight; + if (widthMulti < heightMulti) { + // width is the dominant dimension so scaling will be fixed on that + return Math.floor(widthMulti * fullHeight); + } + else { + // height is the dominant dimension so scaling will be fixed on that + return Math.floor(heightMulti * fullHeight); + } + }, +} + diff --git a/src/component-index.js b/src/component-index.js index 8a4035811a..b5f5dd0a53 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -26,10 +26,6 @@ limitations under the License. module.exports.components = {}; module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); -module.exports.components['structures.login.ForgotPassword'] = require('./components/structures/login/ForgotPassword'); -module.exports.components['structures.login.Login'] = require('./components/structures/login/Login'); -module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration'); -module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel'); module.exports.components['structures.RoomStatusBar'] = require('./components/structures/RoomStatusBar'); @@ -38,6 +34,10 @@ module.exports.components['structures.ScrollPanel'] = require('./components/stru module.exports.components['structures.TimelinePanel'] = require('./components/structures/TimelinePanel'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); +module.exports.components['structures.login.ForgotPassword'] = require('./components/structures/login/ForgotPassword'); +module.exports.components['structures.login.Login'] = require('./components/structures/login/Login'); +module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration'); +module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar'); module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar'); @@ -64,10 +64,10 @@ module.exports.components['views.login.LoginHeader'] = require('./components/vie module.exports.components['views.login.PasswordLogin'] = require('./components/views/login/PasswordLogin'); module.exports.components['views.login.RegistrationForm'] = require('./components/views/login/RegistrationForm'); module.exports.components['views.login.ServerConfig'] = require('./components/views/login/ServerConfig'); -module.exports.components['views.messages.MessageEvent'] = require('./components/views/messages/MessageEvent'); module.exports.components['views.messages.MFileBody'] = require('./components/views/messages/MFileBody'); module.exports.components['views.messages.MImageBody'] = require('./components/views/messages/MImageBody'); module.exports.components['views.messages.MVideoBody'] = require('./components/views/messages/MVideoBody'); +module.exports.components['views.messages.MessageEvent'] = require('./components/views/messages/MessageEvent'); module.exports.components['views.messages.TextualBody'] = require('./components/views/messages/TextualBody'); module.exports.components['views.messages.TextualEvent'] = require('./components/views/messages/TextualEvent'); module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody'); @@ -77,6 +77,7 @@ module.exports.components['views.rooms.AuxPanel'] = require('./components/views/ module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile'); module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); module.exports.components['views.rooms.InviteMemberList'] = require('./components/views/rooms/InviteMemberList'); +module.exports.components['views.rooms.LinkPreviewWidget'] = require('./components/views/rooms/LinkPreviewWidget'); module.exports.components['views.rooms.MemberInfo'] = require('./components/views/rooms/MemberInfo'); module.exports.components['views.rooms.MemberList'] = require('./components/views/rooms/MemberList'); module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile'); @@ -90,8 +91,8 @@ module.exports.components['views.rooms.RoomPreviewBar'] = require('./components/ module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings'); module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile'); module.exports.components['views.rooms.RoomTopicEditor'] = require('./components/views/rooms/RoomTopicEditor'); -module.exports.components['views.rooms.SearchableEntityList'] = require('./components/views/rooms/SearchableEntityList'); module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile'); +module.exports.components['views.rooms.SearchableEntityList'] = require('./components/views/rooms/SearchableEntityList'); module.exports.components['views.rooms.SimpleRoomHeader'] = require('./components/views/rooms/SimpleRoomHeader'); module.exports.components['views.rooms.TabCompleteBar'] = require('./components/views/rooms/TabCompleteBar'); module.exports.components['views.rooms.TopUnreadMessagesBar'] = require('./components/views/rooms/TopUnreadMessagesBar'); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 55338abed4..8f5ffd9e56 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -337,6 +337,7 @@ module.exports = React.createClass({ ref={this._collectEventNode.bind(this, eventId)} data-scroll-token={scrollToken}> ); @@ -398,6 +399,15 @@ module.exports = React.createClass({ this.eventNodes[eventId] = node; }, + // once dynamic content in the events load, make the scrollPanel check the + // scroll offsets. + _onWidgetLoad: function() { + var scrollPanel = this.refs.scrollPanel; + if (scrollPanel) { + scrollPanel.forceUpdate(); + } + }, + onResize: function() { dis.dispatch({ action: 'timeline_resize' }, true); }, diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 7128faf3d7..c523042248 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -798,9 +798,9 @@ module.exports = React.createClass({ } } - // once images in the search results load, make the scrollPanel check + // once dynamic content in the search results load, make the scrollPanel check // the scroll offsets. - var onImageLoad = () => { + var onWidgetLoad = () => { var scrollPanel = this.refs.searchResultsPanel; if (scrollPanel) { scrollPanel.checkScroll(); @@ -844,7 +844,7 @@ module.exports = React.createClass({ searchResult={result} searchHighlights={this.state.searchHighlights} resultLink={resultLink} - onImageLoad={onImageLoad}/>); + onWidgetLoad={onWidgetLoad}/>); } return ret; }, diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index ff05cf8609..13f9cf4c19 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -20,6 +20,7 @@ var React = require('react'); var filesize = require('filesize'); var MatrixClientPeg = require('../../../MatrixClientPeg'); +var ImageUtils = require('../../../ImageUtils'); var Modal = require('../../../Modal'); var sdk = require('../../../index'); var dis = require("../../../dispatcher"); @@ -30,31 +31,6 @@ module.exports = React.createClass({ propTypes: { /* the MatrixEvent to show */ mxEvent: React.PropTypes.object.isRequired, - - /* callback called when images in events are loaded */ - onImageLoad: React.PropTypes.func, - }, - - thumbHeight: function(fullWidth, fullHeight, thumbWidth, thumbHeight) { - if (!fullWidth || !fullHeight) { - // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even - // log this because it's spammy - return undefined; - } - if (fullWidth < thumbWidth && fullHeight < thumbHeight) { - // no scaling needs to be applied - return fullHeight; - } - var widthMulti = thumbWidth / fullWidth; - var heightMulti = thumbHeight / fullHeight; - if (widthMulti < heightMulti) { - // width is the dominant dimension so scaling will be fixed on that - return Math.floor(widthMulti * fullHeight); - } - else { - // height is the dominant dimension so scaling will be fixed on that - return Math.floor(heightMulti * fullHeight); - } }, onClick: function onClick(ev) { @@ -71,6 +47,7 @@ module.exports = React.createClass({ if (content.info) { params.width = content.info.w; params.height = content.info.h; + params.fileSize = content.info.size; } Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); @@ -134,7 +111,9 @@ module.exports = React.createClass({ // the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box //console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth); - if (content.info) thumbHeight = this.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight); + if (content.info) { + thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight); + } this.refs.image.style.height = thumbHeight + "px"; // console.log("Image height now", thumbHeight); }, @@ -152,8 +131,7 @@ module.exports = React.createClass({ {content.body} + onMouseLeave={this.onImageLeave} />
diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 34d6d53924..1313ce6b00 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -38,15 +38,18 @@ module.exports = React.createClass({ /* link URL for the highlights */ highlightLink: React.PropTypes.string, - /* callback called when images in events are loaded */ - onImageLoad: React.PropTypes.func, + /* callback called when dynamic content in events are loaded */ + onWidgetLoad: React.PropTypes.func, }, + getEventTileOps: function() { + return this.refs.body ? this.refs.body.getEventTileOps() : null; + }, render: function() { - var UnknownMessageTile = sdk.getComponent('messages.UnknownBody'); + var UnknownBody = sdk.getComponent('messages.UnknownBody'); - var tileTypes = { + var bodyTypes = { 'm.text': sdk.getComponent('messages.TextualBody'), 'm.notice': sdk.getComponent('messages.TextualBody'), 'm.emote': sdk.getComponent('messages.TextualBody'), @@ -57,13 +60,13 @@ module.exports = React.createClass({ var content = this.props.mxEvent.getContent(); var msgtype = content.msgtype; - var TileType = UnknownMessageTile; - if (msgtype && tileTypes[msgtype]) { - TileType = tileTypes[msgtype]; + var BodyType = UnknownBody; + if (msgtype && bodyTypes[msgtype]) { + BodyType = bodyTypes[msgtype]; } - return ; + onWidgetLoad={this.props.onWidgetLoad} />; }, }); diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 92447dd1da..ce33a60872 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -22,6 +22,7 @@ var HtmlUtils = require('../../../HtmlUtils'); var linkify = require('linkifyjs'); var linkifyElement = require('linkifyjs/element'); var linkifyMatrix = require('../../../linkify-matrix'); +var sdk = require('../../../index'); linkifyMatrix(linkify); @@ -37,28 +38,84 @@ module.exports = React.createClass({ /* link URL for the highlights */ highlightLink: React.PropTypes.string, + + /* callback for when our widget has loaded */ + onWidgetLoad: React.PropTypes.func, + }, + + getInitialState: function() { + return { + // the URL (if any) to be previewed with a LinkPreviewWidget + // inside this TextualBody. + link: null, + + // track whether the preview widget is hidden + widgetHidden: false, + }; }, componentDidMount: function() { linkifyElement(this.refs.content, linkifyMatrix.options); - if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") - HtmlUtils.highlightDom(ReactDOM.findDOMNode(this)); - }, + var link = this.findLink(this.refs.content.children); + if (link) { + this.setState({ link: link.getAttribute("href") }); - componentDidUpdate: function() { - // XXX: why don't we linkify here? - // XXX: why do we bother doing this on update at all, given events are immutable? + // lazy-load the hidden state of the preview widget from localstorage + if (global.localStorage) { + var hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId()); + this.setState({ widgetHidden: hidden }); + } + } if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") HtmlUtils.highlightDom(ReactDOM.findDOMNode(this)); }, - shouldComponentUpdate: function(nextProps) { + shouldComponentUpdate: function(nextProps, nextState) { // exploit that events are immutable :) return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || nextProps.highlights !== this.props.highlights || - nextProps.highlightLink !== this.props.highlightLink); + nextProps.highlightLink !== this.props.highlightLink || + nextState.link !== this.state.link || + nextState.widgetHidden !== this.state.widgetHidden); + }, + + findLink: function(nodes) { + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + if (node.tagName === "A" && node.getAttribute("href")) { + return node; + } + else if (node.children && node.children.length) { + return this.findLink(node.children) + } + } + }, + + onCancelClick: function(event) { + this.setState({ widgetHidden: true }); + // FIXME: persist this somewhere smarter than local storage + if (global.localStorage) { + global.localStorage.setItem("hide_preview_" + this.props.mxEvent.getId(), "1"); + } + this.forceUpdate(); + }, + + getEventTileOps: function() { + var self = this; + return { + isWidgetHidden: function() { + return self.state.widgetHidden; + }, + + unhideWidget: function() { + self.setState({ widgetHidden: false }); + if (global.localStorage) { + global.localStorage.removeItem("hide_preview_" + self.props.mxEvent.getId()); + } + }, + } }, render: function() { @@ -67,24 +124,38 @@ module.exports = React.createClass({ var body = HtmlUtils.bodyToHtml(content, this.props.highlights, {highlightLink: this.props.highlightLink}); + + var widget; + if (this.state.link && !this.state.widgetHidden) { + var LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget'); + widget = ; + } + switch (content.msgtype) { case "m.emote": var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); return ( * { name } { body } + { widget } ); case "m.notice": return ( { body } + { widget } ); default: // including "m.text" return ( { body } + { widget } ); } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 8771afac36..1ef3b6e6c0 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -105,8 +105,8 @@ module.exports = React.createClass({ /* is this the focused event */ isSelectedEvent: React.PropTypes.bool, - /* callback called when images in events are loaded */ - onImageLoad: React.PropTypes.func, + /* callback called when dynamic content in events are loaded */ + onWidgetLoad: React.PropTypes.func, }, getInitialState: function() { @@ -123,7 +123,7 @@ module.exports = React.createClass({ { return false; } - + return actions.tweaks.highlight; }, @@ -137,6 +137,7 @@ module.exports = React.createClass({ mxEvent: this.props.mxEvent, left: x, top: y, + eventTileOps: this.refs.tile ? this.refs.tile.getEventTileOps() : undefined, onFinished: function() { self.setState({menu: false}); } @@ -343,9 +344,9 @@ module.exports = React.createClass({ { avatar } { sender }
- + onWidgetLoad={this.props.onWidgetLoad} />
); diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js new file mode 100644 index 0000000000..302e0f1e75 --- /dev/null +++ b/src/components/views/rooms/LinkPreviewWidget.js @@ -0,0 +1,129 @@ +/* +Copyright 2016 OpenMarket Ltd + +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. +*/ + +'use strict'; + +var React = require('react'); + +var sdk = require('../../../index'); +var MatrixClientPeg = require('../../../MatrixClientPeg'); +var ImageUtils = require('../../../ImageUtils'); +var Modal = require('../../../Modal'); + +var linkify = require('linkifyjs'); +var linkifyElement = require('linkifyjs/element'); +var linkifyMatrix = require('../../../linkify-matrix'); +linkifyMatrix(linkify); + +module.exports = React.createClass({ + displayName: 'LinkPreviewWidget', + + propTypes: { + link: React.PropTypes.string.isRequired, // the URL being previewed + mxEvent: React.PropTypes.object.isRequired, // the Event associated with the preview + onCancelClick: React.PropTypes.func, // called when the preview's cancel ('hide') button is clicked + onWidgetLoad: React.PropTypes.func, // called when the preview's contents has loaded + }, + + getInitialState: function() { + return { + preview: null + }; + }, + + componentWillMount: function() { + MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((res)=>{ + this.setState( + { preview: res }, + this.props.onWidgetLoad + ); + }, (error)=>{ + console.error("Failed to get preview for " + this.props.link + " " + error); + }); + }, + + componentDidMount: function() { + if (this.refs.description) + linkifyElement(this.refs.description, linkifyMatrix.options); + }, + + componentDidUpdate: function() { + if (this.refs.description) + linkifyElement(this.refs.description, linkifyMatrix.options); + }, + + onImageClick: function(ev) { + var p = this.state.preview; + if (ev.button != 0 || ev.metaKey) return; + ev.preventDefault(); + var ImageView = sdk.getComponent("elements.ImageView"); + + var src = p["og:image"]; + if (src && src.startsWith("mxc://")) { + src = MatrixClientPeg.get().mxcUrlToHttp(src); + } + + var params = { + src: src, + width: p["og:image:width"], + height: p["og:image:height"], + name: p["og:title"] || p["og:description"] || this.props.link, + fileSize: p["matrix:image:size"], + link: this.props.link, + }; + + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + }, + + render: function() { + var p = this.state.preview; + if (!p) return
; + + // FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing? + var image = p["og:image"]; + var imageMaxWidth = 100, imageMaxHeight = 100; + if (image && image.startsWith("mxc://")) { + image = MatrixClientPeg.get().mxcUrlToHttp(image, imageMaxWidth, imageMaxHeight); + } + + var thumbHeight = imageMaxHeight; + if (p["og:image:width"] && p["og:image:height"]) { + thumbHeight = ImageUtils.thumbHeight(p["og:image:width"], p["og:image:height"], imageMaxWidth, imageMaxHeight); + } + + var img; + if (image) { + img =
+ +
+ } + + return ( +
+ { img } +
+
{ p["og:title"] }
+
{ p["og:site_name"] ? (" - " + p["og:site_name"]) : null }
+
+ { p["og:description"] } +
+
+ +
+ ); + } +}); diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 52313430d4..43896e3e83 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -451,7 +451,7 @@ module.exports = React.createClass({ onMemberAvatarClick: function () { var avatarUrl = this.props.member.user.avatarUrl; if(!avatarUrl) return; - + var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(avatarUrl); var ImageView = sdk.getComponent("elements.ImageView"); var params = { diff --git a/src/components/views/rooms/SearchResultTile.js b/src/components/views/rooms/SearchResultTile.js index 1fc0384433..7fac244481 100644 --- a/src/components/views/rooms/SearchResultTile.js +++ b/src/components/views/rooms/SearchResultTile.js @@ -32,7 +32,7 @@ module.exports = React.createClass({ // href for the highlights in this result resultLink: React.PropTypes.string, - onImageLoad: React.PropTypes.func, + onWidgetLoad: React.PropTypes.func, }, render: function() { @@ -56,7 +56,7 @@ module.exports = React.createClass({ if (EventTile.haveTileForEvent(ev)) { ret.push(); + onWidgetLoad={this.props.onWidgetLoad} />); } } return (