Merge pull request #450 from matrix-org/matthew/notif-panel

FilePanel, NotificationPanel and EventTimelineSet support
This commit is contained in:
Matthew Hodgson 2016-09-12 15:57:13 +01:00 committed by GitHub
commit 3fc657bce4
19 changed files with 526 additions and 126 deletions

View file

@ -18,6 +18,8 @@ limitations under the License.
import Matrix from 'matrix-js-sdk';
import utils from 'matrix-js-sdk/lib/utils';
import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline';
import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set';
const localStorage = window.localStorage;
@ -104,6 +106,13 @@ class MatrixClientPeg {
this.matrixClient.setMaxListeners(500);
this.matrixClient.setGuest(Boolean(creds.guest));
var notifTimelineSet = new EventTimelineSet(null, {
timelineSupport: true
});
// XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync.
notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);
this.matrixClient.setNotifTimelineSet(notifTimelineSet);
}
}

View file

@ -224,10 +224,12 @@ var Notifier = {
}
},
onRoomTimeline: function(ev, room, toStartOfTimeline) {
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
if (toStartOfTimeline) return;
if (!room) return;
if (!this.isPrepared) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return;
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
var actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions && actions.notify) {

View file

@ -37,7 +37,8 @@ class UserActivity {
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message.
window.addEventListener('wheel', this._onUserActivity.bind(this), true);
window.addEventListener('wheel', this._onUserActivity.bind(this),
{ passive: true, capture: true });
this.lastActivityAtTs = new Date().getTime();
this.lastDispatchAtTs = 0;
this.activityEndTimer = undefined;
@ -50,7 +51,8 @@ class UserActivity {
document.onmousedown = undefined;
document.onmousemove = undefined;
document.onkeypress = undefined;
window.removeEventListener('wheel', this._onUserActivity.bind(this), true);
window.removeEventListener('wheel', this._onUserActivity.bind(this),
{ passive: true, capture: true });
}
/**

View file

@ -27,8 +27,10 @@ limitations under the License.
module.exports.components = {};
module.exports.components['structures.ContextualMenu'] = require('./components/structures/ContextualMenu');
module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom');
module.exports.components['structures.FilePanel'] = require('./components/structures/FilePanel');
module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat');
module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel');
module.exports.components['structures.NotificationPanel'] = require('./components/structures/NotificationPanel');
module.exports.components['structures.RoomStatusBar'] = require('./components/structures/RoomStatusBar');
module.exports.components['structures.RoomView'] = require('./components/structures/RoomView');
module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel');

View file

@ -0,0 +1,121 @@
/*
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.
*/
var React = require('react');
var ReactDOM = require("react-dom");
var Matrix = require("matrix-js-sdk");
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var dis = require("../../dispatcher");
/*
* Component which shows the filtered file using a TimelinePanel
*/
var FilePanel = React.createClass({
displayName: 'FilePanel',
propTypes: {
roomId: React.PropTypes.string.isRequired,
},
getInitialState: function() {
return {
timelineSet: null,
}
},
componentWillMount: function() {
this.updateTimelineSet(this.props.roomId);
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.roomId !== this.props.roomId) {
// otherwise we race between re-rendering the TimelinePanel and setting the new timelineSet.
//
// FIXME: this race only happens because of the promise returned by getOrCreateFilter().
// We should only need to create the containsUrl filter once per login session, so in practice
// it shouldn't be being done here at all. Then we could just update the timelineSet directly
// without resetting it first, and speed up room-change.
this.setState({ timelineSet: null });
this.updateTimelineSet(nextProps.roomId);
}
},
updateTimelineSet: function(roomId) {
var client = MatrixClientPeg.get();
var room = client.getRoom(roomId);
if (room) {
var filter = new Matrix.Filter(client.credentials.userId);
filter.setDefinition(
{
"room": {
"timeline": {
"contains_url": true
},
}
}
);
// FIXME: we shouldn't be doing this every time we change room - see comment above.
client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then(
(filterId)=>{
filter.filterId = filterId;
var timelineSet = room.getOrCreateFilteredTimelineSet(filter);
this.setState({ timelineSet: timelineSet });
},
(error)=>{
console.error("Failed to get or create file panel filter", error);
}
);
}
else {
console.error("Failed to add filtered timelineSet for FilePanel as no room!");
}
},
render: function() {
// wrap a TimelinePanel with the jump-to-event bits turned off.
var TimelinePanel = sdk.getComponent("structures.TimelinePanel");
var Loader = sdk.getComponent("elements.Spinner");
if (this.state.timelineSet) {
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
return (
<TimelinePanel key={"filepanel_" + this.props.roomId}
className="mx_FilePanel"
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview = { false }
tileShape="file_grid"
opacity={ this.props.opacity }
/>
);
}
else {
return (
<div className="mx_FilePanel">
<Loader/>
</div>
);
}
},
});
module.exports = FilePanel;

View file

@ -60,6 +60,9 @@ module.exports = React.createClass({
// true to suppress the date at the start of the timeline
suppressFirstDateSeparator: React.PropTypes.bool,
// whether to show read receipts
manageReadReceipts: React.PropTypes.bool,
// true if updates to the event list should cause the scroll panel to
// scroll down when we are at the bottom of the window. See ScrollPanel
// for more details.
@ -73,6 +76,12 @@ module.exports = React.createClass({
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number,
// className for the panel
className: React.PropTypes.string.isRequired,
// shape parameter to be passed to EventTiles
tileShape: React.PropTypes.string,
},
componentWillMount: function() {
@ -370,7 +379,10 @@ module.exports = React.createClass({
// Local echos have a send "status".
var scrollToken = mxEv.status ? undefined : eventId;
var readReceipts = this._getReadReceiptsForEvent(mxEv);
var readReceipts;
if (this.props.manageReadReceipts) {
readReceipts = this._getReadReceiptsForEvent(mxEv);
}
ret.push(
<li key={eventId}
@ -383,6 +395,7 @@ module.exports = React.createClass({
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status}
tileShape={this.props.tileShape}
last={last} isSelectedEvent={highlight}/>
</li>
);
@ -503,7 +516,7 @@ module.exports = React.createClass({
style.opacity = this.props.opacity;
return (
<ScrollPanel ref="scrollPanel" className="mx_RoomView_messagePanel mx_fadable"
<ScrollPanel ref="scrollPanel" className={ this.props.className + " mx_fadable" }
onScroll={ this.props.onScroll }
onResize={ this.onResize }
onFillRequest={ this.props.onFillRequest }

View file

@ -0,0 +1,65 @@
/*
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.
*/
var React = require('react');
var ReactDOM = require("react-dom");
var Matrix = require("matrix-js-sdk");
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var dis = require("../../dispatcher");
/*
* Component which shows the global notification list using a TimelinePanel
*/
var NotificationPanel = React.createClass({
displayName: 'NotificationPanel',
propTypes: {
},
render: function() {
// wrap a TimelinePanel with the jump-to-event bits turned off.
var TimelinePanel = sdk.getComponent("structures.TimelinePanel");
var Loader = sdk.getComponent("elements.Spinner");
var timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) {
return (
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
className="mx_NotificationPanel"
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview = { false }
opacity={ this.props.opacity }
tileShape="notif"
/>
);
}
else {
console.error("No notifTimelineSet available!");
return (
<div className="mx_NotificationPanel">
<Loader/>
</div>
);
}
},
});
module.exports = NotificationPanel;

View file

@ -340,8 +340,12 @@ module.exports = React.createClass({
if (this.unmounted) return;
// ignore events for other rooms
if (!room) return;
if (!this.state.room || room.roomId != this.state.room.roomId) return;
// ignore events from filtered timelines
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
if (ev.getType() === "org.matrix.room.preview_urls") {
this._updatePreviewUrlVisibility(room);
}
@ -1570,7 +1574,9 @@ module.exports = React.createClass({
var messagePanel = (
<TimelinePanel ref={this._gatherTimelinePanelRef}
room={this.state.room}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
manageReadReceipts={true}
manageReadMarkers={true}
hidden={hideMessagePanel}
highlightedEventId={this.props.highlightedEventId}
eventId={this.props.eventId}
@ -1579,6 +1585,7 @@ module.exports = React.createClass({
onReadMarkerUpdated={ this._updateTopUnreadMessagesBar }
showUrlPreview = { this.state.showUrlPreview }
opacity={ this.props.opacity }
className="mx_RoomView_messagePanel"
/>);
var topUnreadMessagesBar = null;

View file

@ -50,9 +50,15 @@ var TimelinePanel = React.createClass({
displayName: 'TimelinePanel',
propTypes: {
// The js-sdk Room object for the room whose timeline we are
// representing.
room: React.PropTypes.object.isRequired,
// The js-sdk EventTimelineSet object for the timeline sequence we are
// representing. This may or may not have a room, depending on what it's
// a timeline representing. If it has a room, we maintain RRs etc for
// that room.
timelineSet: React.PropTypes.object.isRequired,
// Enable managing RRs and RMs. These require the timelineSet to have a room.
manageReadReceipts: React.PropTypes.bool,
manageReadMarkers: React.PropTypes.bool,
// true to give the component a 'display: none' style.
hidden: React.PropTypes.bool,
@ -84,6 +90,12 @@ var TimelinePanel = React.createClass({
// maximum number of events to show in a timeline
timelineCap: React.PropTypes.number,
// classname to use for the messagepanel
className: React.PropTypes.string,
// shape property to be passed to EventTiles
tileShape: React.PropTypes.string,
},
statics: {
@ -97,13 +109,18 @@ var TimelinePanel = React.createClass({
getDefaultProps: function() {
return {
timelineCap: 250,
className: 'mx_RoomView_messagePanel',
};
},
getInitialState: function() {
var initialReadMarker =
TimelinePanel.roomReadMarkerMap[this.props.room.roomId]
|| this._getCurrentReadReceipt();
// XXX: we could track RM per TimelineSet rather than per Room.
// but for now we just do it per room for simplicity.
if (this.props.manageReadMarkers) {
var initialReadMarker =
TimelinePanel.roomReadMarkerMap[this.props.timelineSet.room.roomId]
|| this._getCurrentReadReceipt();
}
return {
events: [],
@ -137,7 +154,7 @@ var TimelinePanel = React.createClass({
canForwardPaginate: false,
// start with the read-marker visible, so that we see its animated
// disappearance when swtitching into the room.
// disappearance when switching into the room.
readMarkerVisible: true,
readMarkerEventId: initialReadMarker,
@ -163,8 +180,8 @@ var TimelinePanel = React.createClass({
},
componentWillReceiveProps: function(newProps) {
if (newProps.room !== this.props.room) {
// throw new Error("changing room on a TimelinePanel is not supported");
if (newProps.timelineSet !== this.props.timelineSet) {
// throw new Error("changing timelineSet on a TimelinePanel is not supported");
// regrettably, this does happen; in particular, when joining a
// room with /join. In that case, there are two Rooms in
@ -175,7 +192,7 @@ var TimelinePanel = React.createClass({
//
// for now, just warn about this. But we're going to end up paginating
// both rooms separately, and it's all bad.
console.warn("Replacing room on a TimelinePanel - confusion may ensue");
console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue");
}
if (newProps.eventId != this.props.eventId) {
@ -280,11 +297,13 @@ var TimelinePanel = React.createClass({
this.props.onScroll();
}
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (this.getReadMarkerPosition() < 0) {
this.setState({readMarkerVisible: true});
if (this.props.manageReadMarkers) {
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (this.getReadMarkerPosition() < 0) {
this.setState({readMarkerVisible: true});
}
}
},
@ -304,8 +323,8 @@ var TimelinePanel = React.createClass({
},
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
// ignore events for other rooms
if (room !== this.props.room) return;
// ignore events for other timeline sets
if (data.timeline.getTimelineSet() !== this.props.timelineSet) return;
// ignore anything but real-time updates at the end of the room:
// updates from pagination will happen when the paginate completes.
@ -337,40 +356,42 @@ var TimelinePanel = React.createClass({
var lastEv = events[events.length-1];
// if we're at the end of the live timeline, append the pending events
if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(... this.props.room.getPendingEvents());
if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(... this.props.timelineSet.room.getPendingEvents());
}
var updatedState = {events: events};
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
// read marker is visible.
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userCurrentlyActive.
//
var myUserId = MatrixClientPeg.get().credentials.userId;
var sender = ev.sender ? ev.sender.userId : null;
var callback = null;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
updatedState.readMarkerVisible = true;
} else if(lastEv && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
this._setReadMarker(lastEv.getId(), lastEv.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastEv.getId();
callback = this.props.onReadMarkerUpdated;
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
// read marker is visible.
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userCurrentlyActive.
//
var myUserId = MatrixClientPeg.get().credentials.userId;
var sender = ev.sender ? ev.sender.userId : null;
var callback = null;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
updatedState.readMarkerVisible = true;
} else if(lastEv && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
this._setReadMarker(lastEv.getId(), lastEv.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastEv.getId();
callback = this.props.onReadMarkerUpdated;
}
}
this.setState(updatedState, callback);
});
},
onRoomTimelineReset: function(room) {
if (room !== this.props.room) return;
onRoomTimelineReset: function(room, timelineSet) {
if (timelineSet !== this.props.timelineSet) return;
if (this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) {
this._loadTimeline();
@ -381,7 +402,7 @@ var TimelinePanel = React.createClass({
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.room) return;
if (room !== this.props.timelineSet.room) return;
// we could skip an update if the event isn't in our timeline,
// but that's probably an early optimisation.
@ -392,7 +413,7 @@ var TimelinePanel = React.createClass({
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.room) return;
if (room !== this.props.timelineSet.room) return;
this.forceUpdate();
},
@ -401,7 +422,7 @@ var TimelinePanel = React.createClass({
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.room) return;
if (room !== this.props.timelineSet.room) return;
this._reloadEvents();
},
@ -409,12 +430,13 @@ var TimelinePanel = React.createClass({
sendReadReceipt: function() {
if (!this.refs.messagePanel) return;
if (!this.props.manageReadReceipts) return;
// if we are scrolled to the bottom, do a quick-reset of our unreadNotificationCount
// to avoid having to wait from the remote echo from the homeserver.
if (this.isAtEndOfLiveTimeline()) {
this.props.room.setUnreadNotificationCount('total', 0);
this.props.room.setUnreadNotificationCount('highlight', 0);
this.props.timelineSet.room.setUnreadNotificationCount('total', 0);
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
// XXX: i'm a bit surprised we don't have to emit an event or dispatch to get this picked up
}
@ -461,6 +483,7 @@ var TimelinePanel = React.createClass({
// if the read marker is on the screen, we can now assume we've caught up to the end
// of the screen, so move the marker down to the bottom of the screen.
updateReadMarker: function() {
if (!this.props.manageReadMarkers) return;
if (this.getReadMarkerPosition() !== 0) {
return;
}
@ -498,6 +521,8 @@ var TimelinePanel = React.createClass({
// advance the read marker past any events we sent ourselves.
_advanceReadMarkerPastMyEvents: function() {
if (!this.props.manageReadMarkers) return;
// we call _timelineWindow.getEvents() rather than using
// this.state.events, because react batches the update to the latter, so it
// may not have been updated yet.
@ -548,11 +573,9 @@ var TimelinePanel = React.createClass({
* the container.
*/
jumpToReadMarker: function() {
if (!this.refs.messagePanel)
return;
if (!this.state.readMarkerEventId)
return;
if (!this.props.manageReadMarkers) return;
if (!this.refs.messagePanel) return;
if (!this.state.readMarkerEventId) return;
// we may not have loaded the event corresponding to the read-marker
// into the _timelineWindow. In that case, attempts to scroll to it
@ -579,10 +602,12 @@ var TimelinePanel = React.createClass({
/* update the read-up-to marker to match the read receipt
*/
forgetReadMarker: function() {
if (!this.props.manageReadMarkers) return;
var rmId = this._getCurrentReadReceipt();
// see if we know the timestamp for the rr event
var tl = this.props.room.getTimelineForEvent(rmId);
var tl = this.props.timelineSet.getTimelineForEvent(rmId);
var rmTs;
if (tl) {
var event = tl.getEvents().find((e) => { return e.getId() == rmId });
@ -622,7 +647,9 @@ var TimelinePanel = React.createClass({
// 0: read marker is visible
// +1: read marker is below the window
getReadMarkerPosition: function() {
if (!this.refs.messagePanel) { return null; }
if (!this.props.manageReadMarkers) return null;
if (!this.refs.messagePanel) return null;
var ret = this.refs.messagePanel.getReadMarkerPosition();
if (ret !== null) {
return ret;
@ -630,7 +657,7 @@ var TimelinePanel = React.createClass({
// the messagePanel doesn't know where the read marker is.
// if we know the timestamp of the read marker, make a guess based on that.
var rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.room.roomId];
var rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.timelineSet.roomId];
if (rmTs && this.state.events.length > 0) {
if (rmTs < this.state.events[0].getTs()) {
return -1;
@ -691,7 +718,7 @@ var TimelinePanel = React.createClass({
*/
_loadTimeline: function(eventId, pixelOffset, offsetBase) {
this._timelineWindow = new Matrix.TimelineWindow(
MatrixClientPeg.get(), this.props.room,
MatrixClientPeg.get(), this.props.timelineSet,
{windowLimit: this.props.timelineCap});
var onLoaded = () => {
@ -745,7 +772,7 @@ var TimelinePanel = React.createClass({
// go via the dispatcher so that the URL is updated
dis.dispatch({
action: 'view_room',
room_id: this.props.room.roomId,
room_id: this.props.timelineSet.roomId,
});
};
}
@ -807,7 +834,7 @@ var TimelinePanel = React.createClass({
// if we're at the end of the live timeline, append the pending events
if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(... this.props.room.getPendingEvents());
events.push(... this.props.timelineSet.getPendingEvents());
}
return events;
@ -873,11 +900,13 @@ var TimelinePanel = React.createClass({
return null;
var myUserId = client.credentials.userId;
return this.props.room.getEventReadUpTo(myUserId, ignoreSynthesized);
return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized);
},
_setReadMarker: function(eventId, eventTs, inhibitSetState) {
if (TimelinePanel.roomReadMarkerMap[this.props.room.roomId] == eventId) {
var roomId = this.props.timelineSet.room.roomId;
if (TimelinePanel.roomReadMarkerMap[roomId] == eventId) {
// don't update the state (and cause a re-render) if there is
// no change to the RM.
return;
@ -885,11 +914,11 @@ var TimelinePanel = React.createClass({
// ideally we'd sync these via the server, but for now just stash them
// in a map.
TimelinePanel.roomReadMarkerMap[this.props.room.roomId] = eventId;
TimelinePanel.roomReadMarkerMap[roomId] = eventId;
// in order to later figure out if the read marker is
// above or below the visible timeline, we stash the timestamp.
TimelinePanel.roomReadMarkerTsMap[this.props.room.roomId] = eventTs;
TimelinePanel.roomReadMarkerTsMap[roomId] = eventTs;
if (inhibitSetState) {
return;
@ -919,7 +948,7 @@ var TimelinePanel = React.createClass({
// exist.
if (this.state.timelineLoading) {
return (
<div className="mx_RoomView_messagePanel mx_RoomView_messageListWrapper">
<div className={ this.props.className + " mx_RoomView_messageListWrapper" }>
<Loader />
</div>
);
@ -946,11 +975,14 @@ var TimelinePanel = React.createClass({
readMarkerVisible={ this.state.readMarkerVisible }
suppressFirstDateSeparator={ this.state.canBackPaginate }
showUrlPreview = { this.props.showUrlPreview }
manageReadReceipts = { this.props.manageReadReceipts }
ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom }
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
opacity={ this.props.opacity }
className={ this.props.className }
tileShape={ this.props.tileShape }
/>
);
},

View file

@ -57,18 +57,34 @@ module.exports = React.createClass({
var TintableSvg = sdk.getComponent("elements.TintableSvg");
if (httpUrl) {
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {text}
</a>
</div>
</span>
);
if (this.props.tileShape === "file_grid") {
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<a className="mx_ImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
{ content.body && content.body.length > 0 ? content.body : "Attachment" }
</a>
<div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div>
</div>
</span>
);
}
else {
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {text}
</a>
</div>
</span>
);
}
} else {
var extra = text ? ': '+text : '';
var extra = text ? (': ' + text) : '';
return <span className="mx_MFileBody">
Invalid file{extra}
</span>

View file

@ -123,6 +123,30 @@ module.exports = React.createClass({
var content = this.props.mxEvent.getContent();
var cli = MatrixClientPeg.get();
var download;
if (this.props.tileShape === "file_grid") {
download = (
<div className="mx_MImageBody_download">
<a className="mx_MImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
{content.body}
</a>
<div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div>
</div>
);
}
else {
download = (
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" })
</a>
</div>
);
}
var thumbUrl = this._getThumbUrl();
if (thumbUrl) {
return (
@ -133,12 +157,7 @@ module.exports = React.createClass({
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
</a>
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" })
</a>
</div>
{ download }
</span>
);
} else if (content.body) {

View file

@ -69,12 +69,37 @@ module.exports = React.createClass({
}
}
var download;
if (this.props.tileShape === "file_grid") {
download = (
<div className="mx_MImageBody_download">
<a className="mx_MImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
{content.body}
</a>
<div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div>
</div>
);
}
else {
download = (
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener">
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" })
</a>
</div>
);
}
return (
<span className="mx_MVideoBody">
<video className="mx_MVideoBody" src={cli.mxcUrlToHttp(content.url)} alt={content.body}
controls preload={preload} autoPlay={false}
height={height} width={width} poster={poster}>
</video>
{ download }
</span>
);
},

View file

@ -37,6 +37,9 @@ module.exports = React.createClass({
/* callback called when dynamic content in events are loaded */
onWidgetLoad: React.PropTypes.func,
/* the shsape of the tile, used */
tileShape: React.PropTypes.string,
},
getEventTileOps: function() {
@ -69,6 +72,7 @@ module.exports = React.createClass({
return <BodyType ref="body" mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />;
},
});

View file

@ -93,8 +93,9 @@ module.exports = React.createClass({
}
else {
joinText = (<span>
Join as <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice')}} href="#">voice</a>&nbsp;
or <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video') }} href="#">video</a>.
Join as <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice')}}
href="#">voice</a> or <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video') }}
href="#">video</a>.
</span>);
}

View file

@ -128,6 +128,15 @@ module.exports = React.createClass({
/* the status of this event - ie, mxEvent.status. Denormalised to here so
* that we can tell when it changes. */
eventSendStatus: React.PropTypes.string,
/* the shape of the tile. by default, the layout is intended for the
* normal room timeline. alternative values are: "file_list", "file_grid"
* and "notif". This could be done by CSS, but it'd be horribly inefficient.
* It could also be done by subclassing EventTile, but that'd be quite
* boiilerplatey. So just make the necessary render decisions conditional
* for now.
*/
tileShape: React.PropTypes.string,
},
getInitialState: function() {
@ -382,18 +391,16 @@ module.exports = React.createClass({
this.props.eventSendStatus
) !== -1,
mx_EventTile_notSent: this.props.eventSendStatus == 'not_sent',
mx_EventTile_highlight: this.shouldHighlight(),
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.continuation,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual,
menu: this.state.menu,
mx_EventTile_verified: this.state.verified == true,
mx_EventTile_unverified: this.state.verified == false,
});
var timestamp = <a href={ "#/room/" + this.props.mxEvent.getRoomId() +"/"+ this.props.mxEvent.getId() }>
<MessageTimestamp ts={this.props.mxEvent.getTs()} />
</a>
var permalink = "#/room/" + this.props.mxEvent.getRoomId() +"/"+ this.props.mxEvent.getId();
var readAvatars = this.getReadAvatars();
@ -401,7 +408,10 @@ module.exports = React.createClass({
let avatarSize;
let needsSenderProfile;
if (isInfoMessage) {
if (this.props.tileShape === "notif") {
avatarSize = 24;
needsSenderProfile = true;
} else if (isInfoMessage) {
// a small avatar, with no sender profile, for emotes and
// joins/parts/etc
avatarSize = 14;
@ -428,35 +438,93 @@ module.exports = React.createClass({
if (needsSenderProfile) {
let aux = null;
if (msgtype === 'm.image') aux = "sent an image";
else if (msgtype === 'm.video') aux = "sent a video";
else if (msgtype === 'm.file') aux = "uploaded a file";
if (!this.props.tileShape) {
if (msgtype === 'm.image') aux = "sent an image";
else if (msgtype === 'm.video') aux = "sent a video";
else if (msgtype === 'm.file') aux = "uploaded a file";
sender = <SenderProfile onClick={ this.onSenderProfileClick } mxEvent={this.props.mxEvent} aux={aux} />;
}
else {
sender = <SenderProfile mxEvent={this.props.mxEvent} />;
}
sender = <SenderProfile onClick={ this.onSenderProfileClick } mxEvent={this.props.mxEvent} aux={aux} />;
}
var editButton = (
<img className="mx_EventTile_editButton" src="img/icon_context_message.svg" width="19" height="19" alt="Options" title="Options" onClick={this.onEditClicked} />
);
return (
<div className={classes}>
<div className="mx_EventTile_msgOption">
{ readAvatars }
if (this.props.tileShape === "notif") {
var room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
return (
<div className={classes}>
<div className="mx_EventTile_roomName">
<a href={ permalink }>
{ room.name }
</a>
</div>
<div className="mx_EventTile_senderDetails">
{ avatar }
<a href={ permalink }>
{ sender }
<MessageTimestamp ts={this.props.mxEvent.getTs()} />
</a>
</div>
<div className="mx_EventTile_line" >
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
</div>
</div>
{ avatar }
{ sender }
<div className="mx_EventTile_line">
{ timestamp }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
{ editButton }
);
}
else if (this.props.tileShape === "file_grid") {
return (
<div className={classes}>
<div className="mx_EventTile_line" >
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />
</div>
<a className="mx_EventTile_senderDetailsLink" href={ permalink }>
<div className="mx_EventTile_senderDetails">
{ sender }
<MessageTimestamp ts={this.props.mxEvent.getTs()} />
</div>
</a>
</div>
</div>
);
);
}
else {
return (
<div className={classes}>
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{ avatar }
{ sender }
<div className="mx_EventTile_line">
<a href={ permalink }>
<MessageTimestamp ts={this.props.mxEvent.getTs()} />
</a>
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
{ editButton }
</div>
</div>
);
}
},
});

View file

@ -78,7 +78,7 @@ export default class MessageComposer extends React.Component {
let fileList = [];
for (let i=0; i<files.length; i++) {
fileList.push(<li>
fileList.push(<li key={i}>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name}
</li>);
}

View file

@ -147,8 +147,10 @@ module.exports = React.createClass({
this._updateStickyHeaders(true, scrollToPosition);
},
onRoomTimeline: function(ev, room, toStartOfTimeline) {
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
if (toStartOfTimeline) return;
if (!room) return;
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
this._delayedRefreshRoomList();
},

View file

@ -106,14 +106,16 @@ module.exports = React.createClass({
onMouseEnter: function() {
this.setState( { hover : true });
this.badgeOnMouseEnter();
},
onMouseLeave: function() {
this.setState( { hover : false });
this.badgeOnMouseLeave();
},
badgeOnMouseEnter: function() {
// Only allow none guests to access the context menu
// Only allow non-guests to access the context menu
// and only change it if it needs to change
if (!MatrixClientPeg.get().isGuest() && !this.state.badgeHover) {
this.setState( { badgeHover : true } );
@ -241,7 +243,7 @@ module.exports = React.createClass({
badgeContent = '\u200B';
}
badge = <div className={ badgeClasses } onClick={this.onBadgeClicked} onMouseEnter={this.badgeOnMouseEnter} onMouseLeave={this.badgeOnMouseLeave}>{ badgeContent }</div>;
badge = <div className={ badgeClasses } onClick={this.onBadgeClicked}>{ badgeContent }</div>;
const EmojiText = sdk.getComponent('elements.EmojiText');
var label;

View file

@ -35,6 +35,7 @@ var USER_ID = '@me:localhost';
describe('TimelinePanel', function() {
var sandbox;
var timelineSet;
var room;
var client;
var timeline;
@ -58,10 +59,16 @@ describe('TimelinePanel', function() {
test_utils.beforeEach(this);
sandbox = test_utils.stubClient(sandbox);
timeline = new jssdk.EventTimeline(ROOM_ID);
room = sinon.createStubInstance(jssdk.Room);
room.getLiveTimeline.returns(timeline);
room.getPendingEvents.returns([]);
room.roomId = ROOM_ID;
timelineSet = sinon.createStubInstance(jssdk.EventTimelineSet);
timelineSet.getPendingEvents.returns([]);
timelineSet.room = room;
timeline = new jssdk.EventTimeline(timelineSet);
timelineSet.getLiveTimeline.returns(timeline);
client = peg.get();
client.credentials = {userId: USER_ID};
@ -95,7 +102,7 @@ describe('TimelinePanel', function() {
var scrollDefer;
var panel = ReactDOM.render(
<TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}}
<TimelinePanel timelineSet={timelineSet} onScroll={() => {scrollDefer.resolve()}}
/>,
parentDiv,
);
@ -143,7 +150,10 @@ describe('TimelinePanel', function() {
// a new event!
var ev = mkMessage();
timeline.addEvent(ev);
panel.onRoomTimeline(ev, room, false, false, {liveEvent: true});
panel.onRoomTimeline(ev, room, false, false, {
liveEvent: true,
timeline: timeline,
});
// that won't make much difference, because we don't paginate
// unless we're at the bottom of the timeline, but a scroll event
@ -178,7 +188,7 @@ describe('TimelinePanel', function() {
});
var panel = ReactDOM.render(
<TimelinePanel room={room}/>,
<TimelinePanel timelineSet={timelineSet}/>,
parentDiv
);
@ -226,7 +236,7 @@ describe('TimelinePanel', function() {
var scrollDefer;
var panel = ReactDOM.render(
<TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}}
<TimelinePanel timelineSet={timelineSet} onScroll={() => {scrollDefer.resolve()}}
timelineCap={TIMELINE_CAP}
/>,
parentDiv