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({
+ 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: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 (