Merge pull request #3654 from vector-im/matthew/quick-search

Optimise RoomList and implement quick-search functionality on it.
This commit is contained in:
Matthew Hodgson 2017-04-20 11:26:08 +01:00 committed by GitHub
commit 8f20fcfa6b
6 changed files with 311 additions and 86 deletions

View file

@ -58,7 +58,6 @@
"favico.js": "^0.3.10",
"filesize": "^3.1.2",
"flux": "~2.0.3",
"gemini-scrollbar": "matrix-org/gemini-scrollbar#b302279",
"gfm.css": "^1.1.1",
"highlight.js": "^9.0.0",
"linkifyjs": "^2.1.3",
@ -71,7 +70,7 @@
"react-dnd": "^2.1.4",
"react-dnd-html5-backend": "^2.1.2",
"react-dom": "^15.4.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#39d858c",
"sanitize-html": "^1.11.1",
"ua-parser-js": "^0.7.10",
"url": "^0.11.0"

View file

@ -40,6 +40,8 @@ import structures$RoomDirectory from './components/structures/RoomDirectory';
structures$RoomDirectory && (module.exports.components['structures.RoomDirectory'] = structures$RoomDirectory);
import structures$RoomSubList from './components/structures/RoomSubList';
structures$RoomSubList && (module.exports.components['structures.RoomSubList'] = structures$RoomSubList);
import structures$RoomSubListHeader from './components/structures/RoomSubListHeader';
structures$RoomSubListHeader && (module.exports.components['structures.RoomSubListHeader'] = structures$RoomSubListHeader);
import structures$SearchBox from './components/structures/SearchBox';
structures$SearchBox && (module.exports.components['structures.SearchBox'] = structures$SearchBox);
import structures$ViewSource from './components/structures/ViewSource';

View file

@ -19,9 +19,11 @@ limitations under the License.
var React = require('react');
var DragDropContext = require('react-dnd').DragDropContext;
var HTML5Backend = require('react-dnd-html5-backend');
var KeyCode = require('matrix-react-sdk/lib/KeyCode');
var sdk = require('matrix-react-sdk')
var dis = require('matrix-react-sdk/lib/dispatcher');
var VectorConferenceHandler = require('../../VectorConferenceHandler');
var CallHandler = require("matrix-react-sdk/lib/CallHandler");
@ -40,6 +42,10 @@ var LeftPanel = React.createClass({
};
},
componentWillMount: function() {
this.focusedElement = null;
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
},
@ -62,6 +68,91 @@ var LeftPanel = React.createClass({
}
},
_onFocus: function(ev) {
this.focusedElement = ev.target;
},
_onBlur: function(ev) {
this.focusedElement = null;
},
_onKeyDown: function(ev) {
if (!this.focusedElement) return;
let handled = false;
switch (ev.keyCode) {
case KeyCode.UP:
this._onMoveFocus(true);
handled = true;
break;
case KeyCode.DOWN:
this._onMoveFocus(false);
handled = true;
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
},
_onMoveFocus: function(up) {
var element = this.focusedElement;
// unclear why this isn't needed
// var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
// this.focusDirection = up;
var descending = false; // are we currently descending or ascending through the DOM tree?
var classes;
do {
var child = up ? element.lastElementChild : element.firstElementChild;
var sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
}
else if (sibling) {
element = sibling;
}
else {
descending = false;
element = element.parentElement;
}
}
else {
if (sibling) {
element = sibling;
descending = true;
}
else {
element = element.parentElement;
}
}
if (element) {
classes = element.classList;
if (classes.contains("mx_LeftPanel")) { // we hit the top
element = up ? element.lastElementChild : element.firstElementChild;
descending = true;
}
}
} while(element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_SearchBox_search") ||
classes.contains("mx_RoomSubList_ellipsis")));
if (element) {
element.focus();
this.focusedElement = element;
this.focusedDescending = descending;
}
},
_recheckCallElement: function(selectedRoomId) {
// if we aren't viewing a room with an ongoing call, but there is an
// active call, show the call element - we need to do this to make
@ -120,7 +211,8 @@ var LeftPanel = React.createClass({
}
return (
<aside className={classes} style={{ opacity: this.props.opacity }}>
<aside className={classes} style={{ opacity: this.props.opacity }}
onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
<SearchBox collapsed={ this.props.collapsed } onSearch={ this.onSearch } />
{ collapseButton }
{ callPreview }

View file

@ -27,8 +27,10 @@ var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
var RoomNotifs = require('matrix-react-sdk/lib/RoomNotifs');
var FormattingUtils = require('matrix-react-sdk/lib/utils/FormattingUtils');
var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
var ConstantTimeDispatcher = require('matrix-react-sdk/lib/ConstantTimeDispatcher');
var RoomSubListHeader = require('./RoomSubListHeader.js');
// turn this on for drop & drag console debugging galore
// turn this on for drag & drop console debugging galore
var debug = false;
const TRUNCATE_AT = 10;
@ -71,14 +73,12 @@ var RoomSubList = React.createClass({
order: React.PropTypes.string.isRequired,
// undefined if no room is selected (eg we are showing settings)
selectedRoom: React.PropTypes.string,
startAsHidden: React.PropTypes.bool,
showSpinner: React.PropTypes.bool, // true to show a spinner if 0 elements when expanded
collapsed: React.PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: React.PropTypes.func,
alwaysShowHeader: React.PropTypes.bool,
selectedRoom: React.PropTypes.string,
incomingCall: React.PropTypes.object,
onShowMoreRooms: React.PropTypes.func,
searchFilter: React.PropTypes.string,
@ -100,13 +100,31 @@ var RoomSubList = React.createClass({
},
componentWillMount: function() {
constantTimeDispatcher.register("RoomSubList.sort", this.props.tagName, this.onSort);
constantTimeDispatcher.register("RoomSubList.refreshHeader", this.props.tagName, this.onRefresh);
this.sortList(this.applySearchFilter(this.props.list, this.props.searchFilter), this.props.order);
this._fixUndefinedOrder(this.props.list);
},
componentWillUnmount: function() {
constantTimeDispatcher.unregister("RoomSubList.sort", this.props.tagName, this.onSort);
constantTimeDispatcher.unregister("RoomSubList.refreshHeader", this.props.tagName, this.onRefresh);
},
componentWillReceiveProps: function(newProps) {
// order the room list appropriately before we re-render
//if (debug) console.log("received new props, list = " + newProps.list);
this.sortList(this.applySearchFilter(newProps.list, newProps.searchFilter), newProps.order);
this._fixUndefinedOrder(newProps.list);
},
onSort: function() {
this.sortList(this.applySearchFilter(this.props.list, this.props.searchFilter), this.props.order);
// we deliberately don't waste time trying to fix undefined ordering here
},
onRefresh: function() {
this.forceUpdate();
},
applySearchFilter: function(list, filter) {
@ -119,7 +137,7 @@ var RoomSubList = React.createClass({
// The header is collapsable if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() {
var stuck = this.refs.header.dataset.stuck;
var stuck = this.refs.header.refs.header.dataset.stuck;
if (this.state.hidden || stuck === undefined || stuck === "none") {
return true;
} else {
@ -142,7 +160,7 @@ var RoomSubList = React.createClass({
this.props.onHeaderClick(isHidden);
} else {
// The header is stuck, so the click is to be interpreted as a scroll to the header
this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition);
this.props.onHeaderClick(this.state.hidden, this.refs.header.refs.header.dataset.originalPosition);
}
},
@ -211,9 +229,6 @@ var RoomSubList = React.createClass({
if (order === "manual") comparator = this.manualComparator;
if (order === "recent") comparator = this.recentsComparator;
// Fix undefined orders here, and make sure the backend gets updated as well
this._fixUndefinedOrder(list);
//if (debug) console.log("sorting list for sublist " + this.props.label + " with length " + list.length + ", this.props.list = " + this.props.list);
this.setState({ sortedList: list.sort(comparator) });
},
@ -248,10 +263,9 @@ var RoomSubList = React.createClass({
if (badges) {
result[0] += notificationCount;
if (highlight) {
result[1] = true;
}
}
result[1] |= highlight;
}
return result;
}, [0, false]);
@ -359,7 +373,6 @@ var RoomSubList = React.createClass({
var self = this;
var DNDRoomTile = sdk.getComponent("rooms.DNDRoomTile");
return this.state.sortedList.map(function(room) {
var selected = room.roomId == self.props.selectedRoom;
// XXX: is it evil to pass in self as a prop to RoomTile?
return (
<DNDRoomTile
@ -367,9 +380,7 @@ var RoomSubList = React.createClass({
roomSubList={ self }
key={ room.roomId }
collapsed={ self.props.collapsed || false}
selected={ selected }
unread={ Unread.doesRoomHaveUnreadMessages(room) }
highlight={ room.getUnreadNotificationCount('highlight') > 0 || self.props.label === 'Invites' }
selectedRoom={ self.props.selectedRoom }
isInvite={ self.props.label === 'Invites' }
refreshSubList={ self._updateSubListCount }
incomingCall={ null }
@ -379,70 +390,6 @@ var RoomSubList = React.createClass({
});
},
_getHeaderJsx: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var subListNotifications = this.roomNotificationCount();
var subListNotifCount = subListNotifications[0];
var subListNotifHighlight = subListNotifications[1];
var roomCount = this.props.list.length > 0 ? this.props.list.length : '';
var chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden,
});
var badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
var badge;
if (subListNotifCount > 0) {
badge = <div className={badgeClasses}>{ FormattingUtils.formatCount(subListNotifCount) }</div>;
}
// When collapsed, allow a long hover on the header to show user
// the full tag name and room count
var title;
if (this.props.collapsed) {
title = this.props.label;
if (roomCount !== '') {
title += " [" + roomCount + "]";
}
}
var incomingCall;
if (this.props.incomingCall) {
var self = this;
// Check if the incoming call is for this section
var incomingCallRoom = this.props.list.filter(function(room) {
return self.props.incomingCall.roomId === room.roomId;
});
if (incomingCallRoom.length === 1) {
var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall = <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={ this.props.incomingCall }/>;
}
}
var tabindex = this.props.searchFilter === "" ? "0" : "-1";
return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
{ this.props.collapsed ? '' : this.props.label }
<div className="mx_RoomSubList_roomCount">{ roomCount }</div>
<div className={chevronClasses}></div>
{ badge }
{ incomingCall }
</AccessibleButton>
</div>
);
},
_createOverflowTile: function(overflowCount, totalCount) {
var content = <div className="mx_RoomSubList_chevronDown"></div>;
@ -498,7 +445,7 @@ var RoomSubList = React.createClass({
// gets triggered and another list is passed in. Doing it one at a time means that
// we always correctly calculate the highest order for the list - stops multiple
// rooms getting the same order. This is only really relevant for the first time this
// is run with historical room tag data, after that there should only be undefined
// is run with historical room tag data, after that there should only be one undefined
// in the list at a time anyway.
for (let i = 0; i < list.length; i++) {
if (list[i].tags[self.props.tagName] && list[i].tags[self.props.tagName].order === undefined) {
@ -532,6 +479,16 @@ var RoomSubList = React.createClass({
target = <RoomDropTarget label={ 'Drop here to ' + this.props.verb }/>;
}
var roomCount = this.props.list.length > 0 ? this.props.list.length : '';
var isIncomingCallRoom;
if (this.props.incomingCall) {
// Check if the incoming call is for this section
isIncomingCallRoom = this.props.list.find(room=>{
return this.props.incomingCall.roomId === room.roomId;
}) ? true : false;
}
if (this.state.sortedList.length > 0 || this.props.editable) {
var subList;
var classes = "mx_RoomSubList";
@ -550,7 +507,18 @@ var RoomSubList = React.createClass({
return connectDropTarget(
<div>
{ this._getHeaderJsx() }
<RoomSubListHeader
ref='header'
label={ this.props.label }
tagName={ this.props.tagName }
roomCount={ roomCount }
collapsed={ this.props.collapsed }
hidden={ this.state.hidden }
isIncomingCallRoom={ isIncomingCallRoom }
roomNotificationCount={ this.roomNotificationCount() }
onClick={ this.onClick }
onHeaderClick={ this.props.onHeaderClick }
/>
{ subList }
</div>
);
@ -559,7 +527,20 @@ var RoomSubList = React.createClass({
var Loader = sdk.getComponent("elements.Spinner");
return (
<div className="mx_RoomSubList">
{ this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined }
{ this.props.alwaysShowHeader ?
<RoomSubListHeader
ref='header'
label={ this.props.label }
tagName={ this.props.tagName }
roomCount={ roomCount }
collapsed={ this.props.collapsed }
hidden={ this.state.hidden }
isIncomingCallRoom={ isIncomingCallRoom }
roomNotificationCount={ this.roomNotificationCount() }
onClick={ this.onClick }
onHeaderClick={ this.props.onHeaderClick }
/>
: undefined }
{ (this.props.showSpinner && !this.state.hidden) ? <Loader /> : undefined }
</div>
);

View file

@ -0,0 +1,120 @@
/*
Copyright 2017 Vector Creations 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 ReactDOM = require('react-dom');
var classNames = require('classnames');
var sdk = require('matrix-react-sdk')
var FormattingUtils = require('matrix-react-sdk/lib/utils/FormattingUtils');
var RoomNotifs = require('matrix-react-sdk/lib/RoomNotifs');
var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
var ConstantTimeDispatcher = require('matrix-react-sdk/lib/ConstantTimeDispatcher');
module.exports = React.createClass({
displayName: 'RoomSubListHeader',
propTypes: {
label: React.PropTypes.string.isRequired,
tagName: React.PropTypes.string,
roomCount: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.number
]),
collapsed: React.PropTypes.bool.isRequired, // is LeftPanel collapsed?
isIncomingCallRoom: React.PropTypes.bool,
roomNotificationCount: React.PropTypes.array,
hidden: React.PropTypes.bool,
onClick: React.PropTypes.func,
onHeaderClick: React.PropTypes.func,
},
getDefaultProps: function() {
return {
onHeaderClick: function() {}, // NOP
};
},
componentWillMount: function() {
// constantTimeDispatcher.register("RoomSubList.refreshHeader", this.props.tagName, this.onRefresh);
},
componentWillUnmount: function() {
// constantTimeDispatcher.unregister("RoomSubList.refreshHeader", this.props.tagName, this.onRefresh);
},
// onRefresh: function() {
// this.forceUpdate();
// },
render: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var subListNotifications = this.props.roomNotificationCount;
var subListNotifCount = subListNotifications[0];
var subListNotifHighlight = subListNotifications[1];
var chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.props.hidden,
'mx_RoomSubList_chevronDown': !this.props.hidden,
});
var badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
var badge;
if (subListNotifCount > 0) {
badge = <div className={badgeClasses}>{ FormattingUtils.formatCount(subListNotifCount) }</div>;
}
else if (subListNotifHighlight) {
badge = <div className={badgeClasses}>!</div>;
}
// When collapsed, allow a long hover on the header to show user
// the full tag name and room count
var title;
var roomCount = this.props.roomCount;
if (this.props.collapsed) {
title = this.props.label;
if (roomCount !== '') {
title += " [" + roomCount + "]";
}
}
var incomingCall;
if (this.props.isIncomingCallRoom) {
var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall = <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={ this.props.incomingCall }/>;
}
return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={ this.props.onClick } className="mx_RoomSubList_label" tabIndex="0">
{ this.props.collapsed ? '' : this.props.label }
<div className="mx_RoomSubList_roomCount">{ roomCount }</div>
<div className={chevronClasses}></div>
{ badge }
{ incomingCall }
</AccessibleButton>
</div>
);
},
});

View file

@ -21,6 +21,7 @@ var sdk = require('matrix-react-sdk')
var dis = require('matrix-react-sdk/lib/dispatcher');
var rate_limited_func = require('matrix-react-sdk/lib/ratelimitedfunc');
var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
var KeyCode = require('matrix-react-sdk/lib/KeyCode');
module.exports = React.createClass({
displayName: 'SearchBox',
@ -38,10 +39,12 @@ module.exports = React.createClass({
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener('keydown', this._onKeyDown);
},
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
document.removeEventListener('keydown', this._onKeyDown);
},
onAction: function(payload) {
@ -90,6 +93,34 @@ module.exports = React.createClass({
this.onChange();
},
_onKeyDown: function(ev) {
let handled = false;
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
let ctrlCmdOnly;
if (isMac) {
ctrlCmdOnly = ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
ctrlCmdOnly = ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
switch (ev.keyCode) {
case KeyCode.KEY_K:
if (ctrlCmdOnly) {
if (this.refs.search) {
this.refs.search.focus();
this.refs.search.select();
}
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
},
render: function() {
var TintableSvg = sdk.getComponent('elements.TintableSvg');