Merge pull request #103 from matrix-org/kegan/invite-search

Add global search with ability to invite from said list
This commit is contained in:
Kegsay 2016-01-20 14:26:17 +00:00
commit c3c7c90de0
8 changed files with 495 additions and 126 deletions

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
'use strict';
var ContentRepo = require("matrix-js-sdk").ContentRepo;
var MatrixClientPeg = require('./MatrixClientPeg');
module.exports = {
@ -37,6 +37,17 @@ module.exports = {
return url;
},
avatarUrlForUser: function(user, width, height, resizeMethod) {
var url = ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
width, height, resizeMethod
);
if (!url || url.length === 0) {
return null;
}
return url;
},
defaultAvatarUrlForString: function(s) {
var images = [ '76cfa6', '50e2c2', 'f4c371' ];
var total = 0;

107
src/Entities.js Normal file
View file

@ -0,0 +1,107 @@
/*
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.
*/
var React = require('react');
var sdk = require('./index');
/*
* Converts various data models to Entity objects.
*
* Entity objects provide an interface for UI components to use to display
* members in a data-agnostic way. This means they don't need to care if the
* underlying data model is a RoomMember, User or 3PID data structure, it just
* cares about rendering.
*/
class Entity {
constructor(model) {
this.model = model;
}
getJsx() {
return null;
}
matches(queryString) {
return false;
}
}
class MemberEntity extends Entity {
getJsx() {
var MemberTile = sdk.getComponent("rooms.MemberTile");
return (
<MemberTile key={this.model.userId} member={this.model} />
);
}
matches(queryString) {
return this.model.name.toLowerCase().indexOf(queryString.toLowerCase()) === 0;
}
}
class UserEntity extends Entity {
constructor(model, showInviteButton, inviteFn) {
super(model);
this.showInviteButton = Boolean(showInviteButton);
this.inviteFn = inviteFn;
}
onClick() {
if (this.inviteFn) {
this.inviteFn(this.model.userId);
}
}
getJsx() {
var UserTile = sdk.getComponent("rooms.UserTile");
return (
<UserTile key={this.model.userId} user={this.model}
showInviteButton={this.showInviteButton} onClick={this.onClick.bind(this)} />
);
}
matches(queryString) {
var name = this.model.displayName || this.model.userId;
return name.toLowerCase().indexOf(queryString.toLowerCase()) === 0;
}
}
module.exports = {
/**
* @param {RoomMember[]} members
* @return {Entity[]}
*/
fromRoomMembers: function(members) {
return members.map(function(m) {
return new MemberEntity(m);
});
},
/**
* @param {User[]} users
* @param {boolean} showInviteButton
* @param {Function} inviteFn Called with the user ID.
* @return {Entity[]}
*/
fromUsers: function(users, showInviteButton, inviteFn) {
return users.map(function(u) {
return new UserEntity(u, showInviteButton, inviteFn);
})
}
};

View file

@ -65,6 +65,7 @@ module.exports.components['views.messages.MVideoBody'] = require('./components/v
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');
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.MemberInfo'] = require('./components/views/rooms/MemberInfo');
module.exports.components['views.rooms.MemberList'] = require('./components/views/rooms/MemberList');
@ -76,8 +77,10 @@ module.exports.components['views.rooms.RoomList'] = require('./components/views/
module.exports.components['views.rooms.RoomPreviewBar'] = require('./components/views/rooms/RoomPreviewBar');
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.SearchableEntityList'] = require('./components/views/rooms/SearchableEntityList');
module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile');
module.exports.components['views.rooms.TabCompleteBar'] = require('./components/views/rooms/TabCompleteBar');
module.exports.components['views.rooms.UserTile'] = require('./components/views/rooms/UserTile');
module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar');
module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName');
module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword');

View file

@ -0,0 +1,139 @@
/*
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';
var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
var PRESENCE_CLASS = {
"offline": "mx_EntityTile_offline",
"online": "mx_EntityTile_online",
"unavailable": "mx_EntityTile_unavailable"
};
module.exports = React.createClass({
displayName: 'EntityTile',
propTypes: {
name: React.PropTypes.string,
title: React.PropTypes.string,
avatarJsx: React.PropTypes.any, // <BaseAvatar />
presenceState: React.PropTypes.string,
presenceActiveAgo: React.PropTypes.number,
showInviteButton: React.PropTypes.bool,
shouldComponentUpdate: React.PropTypes.func,
onClick: React.PropTypes.func
},
getDefaultProps: function() {
return {
shouldComponentUpdate: function(nextProps, nextState) { return false; },
onClick: function() {},
presenceState: "offline",
presenceActiveAgo: -1,
showInviteButton: false,
};
},
getInitialState: function() {
return {
hover: false
};
},
shouldComponentUpdate: function(nextProps, nextState) {
if (this.state.hover !== nextState.hover) return true;
return this.props.shouldComponentUpdate(nextProps, nextState);
},
mouseEnter: function(e) {
this.setState({ 'hover': true });
},
mouseLeave: function(e) {
this.setState({ 'hover': false });
},
render: function() {
var presenceClass = PRESENCE_CLASS[this.props.presenceState];
var mainClassName = "mx_EntityTile ";
mainClassName += presenceClass;
if (this.state.hover) {
mainClassName += " mx_EntityTile_hover";
}
var nameEl;
if (this.state.hover) {
var PresenceLabel = sdk.getComponent("rooms.PresenceLabel");
nameEl = (
<div className="mx_EntityTile_details">
<img className="mx_EntityTile_chevron" src="img/member_chevron.png" width="8" height="12"/>
<div className="mx_EntityTile_name_hover">{ this.props.name }</div>
<PresenceLabel activeAgo={this.props.presenceActiveAgo}
presenceState={this.props.presenceState} />
</div>
);
}
else {
nameEl = (
<div className="mx_EntityTile_name">
{ this.props.name }
</div>
);
}
var inviteButton;
if (this.props.showInviteButton) {
inviteButton = (
<div className="mx_EntityTile_invite">
<img src="img/plus.svg" width="16" height="16" />
</div>
);
}
var power;
var powerLevel = this.props.powerLevel;
if (powerLevel >= 50 && powerLevel < 99) {
power = <img src="img/mod.svg" className="mx_EntityTile_power" width="16" height="17" alt="Mod"/>;
}
if (powerLevel >= 99) {
power = <img src="img/admin.svg" className="mx_EntityTile_power" width="16" height="17" alt="Admin"/>;
}
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />;
return (
<div className={mainClassName} title={ this.props.title }
onClick={ this.props.onClick } onMouseEnter={ this.mouseEnter }
onMouseLeave={ this.mouseLeave }>
<div className="mx_EntityTile_avatar">
{av}
</div>
{ nameEl }
{ power }
{ inviteButton }
</div>
);
}
});

View file

@ -19,6 +19,7 @@ var Matrix = require("matrix-js-sdk");
var q = require('q');
var MatrixClientPeg = require("../../../MatrixClientPeg");
var Modal = require("../../../Modal");
var Entities = require("../../../Entities");
var sdk = require('../../../index');
var GeminiScrollbar = require('react-gemini-scrollbar');
@ -70,8 +71,19 @@ module.exports = React.createClass({
self.setState({
members: self.roomMembers()
});
// Lazy-load the complete user list for inviting new users
// TODO: Keep this list bleeding-edge up-to-date. Practically speaking,
// it will do for now not being updated as random new users join different
// rooms as this list will be reloaded every room swap.
var room = MatrixClientPeg.get().getRoom(self.props.roomId);
self.userList = MatrixClientPeg.get().getUsers().filter(function(u) {
return !room.hasMembershipState(u.userId, "join");
});
}, 50);
setTimeout
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener.
@ -263,13 +275,23 @@ module.exports = React.createClass({
return latB - latA;
},
makeMemberTiles: function(membership) {
onSearchQueryChanged: function(input) {
this.setState({
searchQuery: input
});
},
makeMemberTiles: function(membership, query) {
var MemberTile = sdk.getComponent("rooms.MemberTile");
query = (query || "").toLowerCase();
var self = this;
var memberList = self.state.members.filter(function(userId) {
var m = self.memberDict[userId];
if (query && m.name.toLowerCase().indexOf(query) !== 0) {
return false;
}
return m.membership == membership;
}).map(function(userId) {
var m = self.memberDict[userId];
@ -284,6 +306,7 @@ module.exports = React.createClass({
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
var room = MatrixClientPeg.get().getRoom(this.props.roomId);
var EntityTile = sdk.getComponent("rooms.EntityTile");
if (room) {
room.currentState.getStateEvents("m.room.third_party_invite").forEach(
function(e) {
@ -294,8 +317,8 @@ module.exports = React.createClass({
return;
}
memberList.push(
<MemberTile key={e.getStateKey()} ref={e.getStateKey()}
customDisplayName={e.getContent().display_name} />
<EntityTile key={e.getStateKey()} ref={e.getStateKey()}
name={e.getContent().display_name} />
)
})
}
@ -304,11 +327,6 @@ module.exports = React.createClass({
return memberList;
},
onPopulateInvite: function(e) {
this.onInvite(this.refs.invite.value);
e.preventDefault();
},
inviteTile: function() {
if (this.state.inviting) {
var Loader = sdk.getComponent("elements.Spinner");
@ -316,17 +334,20 @@ module.exports = React.createClass({
<Loader />
);
} else {
var SearchableEntityList = sdk.getComponent("rooms.SearchableEntityList");
return (
<form onSubmit={this.onPopulateInvite}>
<input className="mx_MemberList_invite" ref="invite" id="mx_MemberList_invite" placeholder="Invite user (email)"/>
</form>
<SearchableEntityList searchPlaceholderText={"Invite / Search"}
onSubmit={this.onInvite}
onQueryChanged={this.onSearchQueryChanged}
entities={Entities.fromUsers(this.userList || [], true, this.onInvite)} />
);
}
},
render: function() {
var invitedSection = null;
var invitedMemberTiles = this.makeMemberTiles('invite');
var invitedMemberTiles = this.makeMemberTiles('invite', this.state.searchQuery);
if (invitedMemberTiles.length > 0) {
invitedSection = (
<div className="mx_MemberList_invited">
@ -343,7 +364,7 @@ module.exports = React.createClass({
{this.inviteTile()}
<div>
<div className="mx_MemberList_wrapper">
{this.makeMemberTiles('join')}
{this.makeMemberTiles('join', this.state.searchQuery)}
</div>
</div>
{invitedSection}

View file

@ -27,9 +27,7 @@ module.exports = React.createClass({
displayName: 'MemberTile',
propTypes: {
member: React.PropTypes.any, // RoomMember
onFinished: React.PropTypes.func,
customDisplayName: React.PropTypes.string // for 3pid invites
member: React.PropTypes.any.isRequired, // RoomMember
},
getInitialState: function() {
@ -37,13 +35,11 @@ module.exports = React.createClass({
},
shouldComponentUpdate: function(nextProps, nextState) {
if (this.state.hover !== nextState.hover) return true;
if (!this.props.member) { return false; } // e.g. 3pid members
if (
this.member_last_modified_time === undefined ||
this.member_last_modified_time < nextProps.member.getLastModifiedTime()
) {
return true
return true;
}
if (
nextProps.member.user &&
@ -55,17 +51,7 @@ module.exports = React.createClass({
return false;
},
mouseEnter: function(e) {
this.setState({ 'hover': true });
},
mouseLeave: function(e) {
this.setState({ 'hover': false });
},
onClick: function(e) {
if (!this.props.member) { return; } // e.g. 3pid members
dis.dispatch({
action: 'view_user',
member: this.props.member,
@ -73,115 +59,42 @@ module.exports = React.createClass({
},
_getDisplayName: function() {
if (this.props.customDisplayName) {
return this.props.customDisplayName;
}
return this.props.member.name;
},
getPowerLabel: function() {
if (!this.props.member) {
return this._getDisplayName();
}
var label = this.props.member.userId + " (power " + this.props.member.powerLevel + ")";
return label;
return this.props.member.userId + " (power " + this.props.member.powerLevel + ")";
},
render: function() {
var member = this.props.member;
var isMyUser = false;
var name = this._getDisplayName();
var active = -1;
var presenceClass = "mx_MemberTile_offline";
if (member) {
if (member.user) {
this.user_last_modified_time = member.user.getLastModifiedTime();
// FIXME: make presence data update whenever User.presence changes...
active = (
(Date.now() - (member.user.lastPresenceTs - member.user.lastActiveAgo)) || -1
);
if (member.user.presence === "online") {
presenceClass = "mx_MemberTile_online";
}
else if (member.user.presence === "unavailable") {
presenceClass = "mx_MemberTile_unavailable";
}
}
this.member_last_modified_time = member.getLastModifiedTime();
isMyUser = MatrixClientPeg.get().credentials.userId == member.userId;
// if (this.props.member && this.props.member.powerLevelNorm > 0) {
// var img = "img/p/p" + Math.floor(20 * this.props.member.powerLevelNorm / 100) + ".png";
// power = <img src={ img } className="mx_MemberTile_power" width="44" height="44" alt=""/>;
// }
var power;
if (this.props.member) {
var powerLevel = this.props.member.powerLevel;
if (powerLevel >= 50 && powerLevel < 99) {
power = <img src="img/mod.svg" className="mx_MemberTile_power" width="16" height="17" alt="Mod"/>;
}
if (powerLevel >= 99) {
power = <img src="img/admin.svg" className="mx_MemberTile_power" width="16" height="17" alt="Admin"/>;
}
}
}
var mainClassName = "mx_MemberTile ";
mainClassName += presenceClass;
if (this.state.hover) {
mainClassName += " mx_MemberTile_hover";
}
var nameEl;
if (this.state.hover && this.props.member) {
var presenceState = (member && member.user) ? member.user.presence : null;
var PresenceLabel = sdk.getComponent("rooms.PresenceLabel");
nameEl = (
<div className="mx_MemberTile_details">
<img className="mx_MemberTile_chevron" src="img/member_chevron.png" width="8" height="12"/>
<div className="mx_MemberTile_userId">{ name }</div>
<PresenceLabel activeAgo={active}
presenceState={presenceState} />
</div>
);
}
else {
nameEl = (
<div className="mx_MemberTile_name">
{ name }
</div>
);
}
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var EntityTile = sdk.getComponent('rooms.EntityTile');
var av;
if (member) {
av = (
<MemberAvatar member={this.props.member} width={36} height={36} />
var member = this.props.member;
var name = this._getDisplayName();
var active = -1;
var presenceState = member.user ? member.user.presence : null;
var av = (
<MemberAvatar member={member} width={36} height={36} />
);
if (member.user) {
this.user_last_modified_time = member.user.getLastModifiedTime();
// FIXME: make presence data update whenever User.presence changes...
active = (
(Date.now() - (member.user.lastPresenceTs - member.user.lastActiveAgo)) || -1
);
}
else {
av = (
<BaseAvatar name={name} width={36} height={36} />
);
}
this.member_last_modified_time = member.getLastModifiedTime();
return (
<div className={mainClassName} title={ this.getPowerLabel() }
onClick={ this.onClick } onMouseEnter={ this.mouseEnter }
onMouseLeave={ this.mouseLeave }>
<div className="mx_MemberTile_avatar">
{ av }
{ power }
</div>
{ nameEl }
</div>
<EntityTile {...this.props} presenceActiveAgo={active} presenceState={presenceState}
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
shouldComponentUpdate={this.shouldComponentUpdate.bind(this)}
name={name} powerLevel={this.props.member.powerLevel} />
);
}
});

View file

@ -0,0 +1,119 @@
/*
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.
*/
var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
var Modal = require("../../../Modal");
var GeminiScrollbar = require('react-gemini-scrollbar');
// A list capable of displaying entities which conform to the SearchableEntity
// interface which is an object containing getJsx(): Jsx and matches(query: string): boolean
var SearchableEntityList = React.createClass({
displayName: 'SearchableEntityList',
propTypes: {
searchPlaceholderText: React.PropTypes.string,
emptyQueryShowsAll: React.PropTypes.bool,
showInputBox: React.PropTypes.bool,
onQueryChanged: React.PropTypes.func, // fn(inputText)
onSubmit: React.PropTypes.func, // fn(inputText)
entities: React.PropTypes.array
},
getDefaultProps: function() {
return {
showInputBox: true,
searchPlaceholderText: "Search",
entities: [],
emptyQueryShowsAll: false,
onSubmit: function() {},
onQueryChanged: function(input) {}
};
},
getInitialState: function() {
return {
query: "",
results: this.getSearchResults("")
};
},
componentWillUnmount: function() {
// pretend the query box was blanked out else filters could still be
// applied to other components which rely on onQueryChanged.
this.props.onQueryChanged("");
},
/**
* Public-facing method to set the input query text to the given input.
* @param {string} input
*/
setQuery: function(input) {
this.setState({
query: input,
results: this.getSearchResults(input)
});
},
onQueryChanged: function(ev) {
var q = ev.target.value;
this.setState({
query: q,
results: this.getSearchResults(q)
});
this.props.onQueryChanged(q);
},
onQuerySubmit: function(ev) {
ev.preventDefault();
this.props.onSubmit(this.state.query);
},
getSearchResults: function(query) {
if (!query || query.length === 0) {
return this.props.emptyQueryShowsAll ? this.props.entities : []
}
return this.props.entities.filter(function(e) {
return e.matches(query);
});
},
render: function() {
var inputBox;
if (this.props.showInputBox) {
inputBox = (
<form onSubmit={this.onQuerySubmit}>
<input className="mx_SearchableEntityList_query" type="text"
onChange={this.onQueryChanged} value={this.state.query}
placeholder={this.props.searchPlaceholderText} />
</form>
);
}
return (
<div>
{inputBox}
<div className="mx_SearchableEntityList_list">
{this.state.results.map((entity) => {
return entity.getJsx();
})}
</div>
</div>
);
}
});
module.exports = SearchableEntityList;

View file

@ -0,0 +1,56 @@
/*
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';
var React = require('react');
var Avatar = require("../../../Avatar");
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
var Modal = require("../../../Modal");
module.exports = React.createClass({
displayName: 'UserTile',
propTypes: {
user: React.PropTypes.any.isRequired // User
},
render: function() {
var EntityTile = sdk.getComponent("rooms.EntityTile");
var user = this.props.user;
var name = user.displayName || user.userId;
var active = -1;
// FIXME: make presence data update whenever User.presence changes...
active = (
(Date.now() - (user.lastPresenceTs - user.lastActiveAgo)) || -1
);
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var avatarJsx = (
<BaseAvatar width={36} height={36} name={name} idName={user.userId}
url={ Avatar.avatarUrlForUser(user, 36, 36, "crop") } />
);
return (
<EntityTile {...this.props} presenceState={user.presence} presenceActiveAgo={active}
name={name} title={user.userId} avatarJsx={avatarJsx} />
);
}
});