Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

URL previewing support #260

Merged
merged 21 commits into from
Apr 11, 2016
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/ImageUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
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 = {
thumbHeight: function(fullWidth, fullHeight, thumbWidth, thumbHeight) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this is a public function, it would be nice to give this a comment saying what it does and what the parameters mean. How does its result relate to the thumbHeight param? Which of the params are allowed to be undefined? I could figure it out, but I shouldn't have to.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

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);
}
},
}

13 changes: 7 additions & 6 deletions src/component-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aside: it would be nice to fix reskindex so that it doesn't rewrite half the index depending on which OS you happen to be using...

module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel');
module.exports.components['structures.RoomStatusBar'] = require('./components/structures/RoomStatusBar');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
10 changes: 10 additions & 0 deletions src/components/structures/MessagePanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ module.exports = React.createClass({
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-token={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
onWidgetLoad={this._onWidgetLoad}
last={last} isSelectedEvent={highlight} />
</li>
);
Expand Down Expand Up @@ -368,6 +369,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.checkScroll();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is sufficient - GeminiPanel needs a forceUpdate so that it can resize the scrollbar.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm. why doesn't scrollPanel.checkScroll() do this already then? Especially as scrollPanel's gemini isn't exposed to MessagePanel?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because checkScroll is called from componentDidUpdate, so you'd end up with a lot of re-renders happening each time anything in the panel updated.

just forceUpdate the ScrollPanel instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, have changed the gemini forceUpdate to the whole scrollpanel forceUpdate. this feels a bit vicious, but i assume you know what you're asking for :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you don't need to do checkScroll as well as forceUpdate.

}
},

onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
},
Expand Down
6 changes: 3 additions & 3 deletions src/components/structures/RoomView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -844,7 +844,7 @@ module.exports = React.createClass({
searchResult={result}
searchHighlights={this.state.searchHighlights}
resultLink={resultLink}
onImageLoad={onImageLoad}/>);
onWidgetLoad={onWidgetLoad}/>);
}
return ret;
},
Expand Down
33 changes: 7 additions & 26 deletions src/components/views/messages/MImageBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -32,29 +33,7 @@ module.exports = React.createClass({
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);
}
onWidgetLoad: React.PropTypes.func,
},

onClick: function onClick(ev) {
Expand All @@ -71,6 +50,7 @@ module.exports = React.createClass({
if (content.info) {
params.width = content.info.w;
params.height = content.info.h;
params.size = content.info.size;
}

Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
Expand Down Expand Up @@ -134,7 +114,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);
},
Expand All @@ -152,8 +134,7 @@ module.exports = React.createClass({
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
alt={content.body}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave}
onLoad={this.props.onImageLoad} />
onMouseLeave={this.onImageLeave} />
</a>
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank">
Expand Down
4 changes: 2 additions & 2 deletions src/components/views/messages/MessageEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ module.exports = React.createClass({
highlightLink: React.PropTypes.string,

/* callback called when images in events are loaded */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More than just images now?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

onImageLoad: React.PropTypes.func,
onWidgetLoad: React.PropTypes.func,
},


Expand All @@ -64,6 +64,6 @@ module.exports = React.createClass({

return <TileType mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onImageLoad={this.props.onImageLoad} />;
onWidgetLoad={this.props.onWidgetLoad} />;
},
});
79 changes: 71 additions & 8 deletions src/components/views/messages/TextualBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -37,28 +38,76 @@ 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 {
link: null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given link never changes, I'd like to see it set before the component is mounted, and probably saved in this rather than state. This will save a render() cycle, and simplify shouldComponentUpdate.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can't be set before the component is mounted, as the links in the message are calculated by linkifyElement() which has to run after the DOM has been created and the component mounted. Therefore we'd still need to re-render() in order to pull in the right LinkPreviewWidget for the links we've discovered - and so might as well still be considered component state. wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd missed that calculating link depended on the DOM having been created, and you're absolutely right.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing that might be nice would be a comment on link in getInitialState saying what it does.


// track whether the preview widget is hidden
// we can't directly use mxEvent's widgetHidden property
// as shouldComponentUpdate needs to be able to do before & after
// comparisons of the property (and we don't pass it in as a top
// level prop to avoid bloating the number of props flying around)
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.props.mxEvent.widgetHidden = hidden;
this.setState({ widgetHidden: hidden });
}
}

if (this.props.mxEvent.getContent().format === "org.matrix.custom.html")
HtmlUtils.highlightDom(ReactDOM.findDOMNode(this));
},

shouldComponentUpdate: function(nextProps) {
findLink: function(nodes) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elsewhere we've put helper methods after the lifecycle functions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

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)
}
}
},

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 ||
nextProps.mxEvent.widgetHidden !== this.state.widgetHidden);
},

componentWillUpdate: function(nextProps, nextState) {
this.setState({ widgetHidden: nextProps.mxEvent.widgetHidden });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://facebook.github.io/react/docs/component-specs.html#updating-componentwillupdate says 'You cannot use this.setState() in this method.'

Perhaps you could update widgetHidden in componentWillReceiveProps instead? Or store it in this instead of this.state and update it in render or componentDidUpdate?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like I've made a spectacular mess of the whole deal of tracking
of whether previews are hidden:

  • localStorage is the hack to persist hiddenness per-client
  • ...but we also track it on mxEvent to avoid constantly hammering
    localStorage
  • ...but we also track it as state on the TextualBody as we need to be
    able to detect when the mxEvent property changes relative to the current
    state of the preview.

Which feels like a spectacular violation of there being one source of
truth, as well as horribly non-encapsulated code :(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes...

Is tracking it in mxEvent actually buying us anything, given we track it in TextualBody anyway?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, because MessageContextualMenu can't get at TextualBody, but it can get at mxEvent (when unhiding the wretched things)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure there is a better way of communicating this from contextualmenu to textualbody than piping it through mxEvent...

Copy link
Member Author

@ara4n ara4n Apr 4, 2016 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could expose an interface object from TextualBody, via a method called getEventOperations or something. That could contain methods which (a) tell you whether the preview is currently hidden, and (b) unhide it. Then you thread that method up towards EventTile, and pass said interface object into MessageContextMenu at the point the menu is created.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a really weird mental blank on this - sorry :( Are you suggesting that EventTile calls TextualEvent.getEventOperations which in turn calls TextualBody.getEventOperations and passes the result into MessageContextMenu as a property?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly so.

Are you suggesting that EventTile calls ...

specifically, EventTile.onEditClick.

EventOperations is a crappy name though. TileOperations, maybe?

},

onCancelClick: function(event) {
this.props.mxEvent.widgetHidden = true;
this.setState({ widgetHidden: true });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Poking this into the js-sdk Event is nasty. I wouldn't mind at least seeing a comment noting this...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

// FIXME: persist this somewhere smarter than local storage
if (global.localStorage) {
global.localStorage.setItem("hide_preview_" + this.props.mxEvent.getId(), "1");
}
this.forceUpdate();
},

render: function() {
Expand All @@ -67,24 +116,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 = <LinkPreviewWidget
link={ this.state.link }
mxEvent={ this.props.mxEvent }
onCancelClick={ this.onCancelClick }
onWidgetLoad={ this.props.onWidgetLoad }/>;
}

switch (content.msgtype) {
case "m.emote":
var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
return (
<span ref="content" className="mx_MEmoteBody mx_EventTile_content">
* { name } { body }
{ widget }
</span>
);
case "m.notice":
return (
<span ref="content" className="mx_MNoticeBody mx_EventTile_content">
{ body }
{ widget }
</span>
);
default: // including "m.text"
return (
<span ref="content" className="mx_MTextBody mx_EventTile_content">
{ body }
{ widget }
</span>
);
}
Expand Down
6 changes: 3 additions & 3 deletions src/components/views/rooms/EventTile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -345,7 +345,7 @@ module.exports = React.createClass({
<div className="mx_EventTile_line">
<EventTileType mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onImageLoad={this.props.onImageLoad} />
onWidgetLoad={this.props.onWidgetLoad} />
</div>
</div>
);
Expand Down
Loading