diff --git a/src/Avatar.js b/src/Avatar.js index e97ed6b673..0ef6c8d07b 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -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; diff --git a/src/Entities.js b/src/Entities.js new file mode 100644 index 0000000000..47103bfb65 --- /dev/null +++ b/src/Entities.js @@ -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 ( + + ); + } + + 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 ( + + ); + } + + 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); + }) + } +}; diff --git a/src/component-index.js b/src/component-index.js index 5a395d5696..2446b26b8d 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -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'); diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js new file mode 100644 index 0000000000..55629aa8b0 --- /dev/null +++ b/src/components/views/rooms/EntityTile.js @@ -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, // + 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 = ( +
+ +
{ this.props.name }
+ +
+ ); + } + else { + nameEl = ( +
+ { this.props.name } +
+ ); + } + + var inviteButton; + if (this.props.showInviteButton) { + inviteButton = ( +
+ +
+ ); + } + + var power; + var powerLevel = this.props.powerLevel; + if (powerLevel >= 50 && powerLevel < 99) { + power = Mod; + } + if (powerLevel >= 99) { + power = Admin; + } + + + var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + + var av = this.props.avatarJsx || ; + + return ( +
+
+ {av} +
+ { nameEl } + { power } + { inviteButton } +
+ ); + } +}); diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index 3e3221992e..9d20f5ab55 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -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( - + ) }) } @@ -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({ ); } else { + var SearchableEntityList = sdk.getComponent("rooms.SearchableEntityList"); + return ( -
- -
+ ); } }, render: function() { var invitedSection = null; - var invitedMemberTiles = this.makeMemberTiles('invite'); + var invitedMemberTiles = this.makeMemberTiles('invite', this.state.searchQuery); if (invitedMemberTiles.length > 0) { invitedSection = (
@@ -343,7 +364,7 @@ module.exports = React.createClass({ {this.inviteTile()}
- {this.makeMemberTiles('join')} + {this.makeMemberTiles('join', this.state.searchQuery)}
{invitedSection} diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index 0f40180274..d5c124ad21 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -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 = ; - // } - - var power; - if (this.props.member) { - var powerLevel = this.props.member.powerLevel; - if (powerLevel >= 50 && powerLevel < 99) { - power = Mod; - } - if (powerLevel >= 99) { - power = 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 = ( -
- -
{ name }
- -
- ); - } - else { - nameEl = ( -
- { name } -
- ); - } - var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + var EntityTile = sdk.getComponent('rooms.EntityTile'); - var av; - if (member) { - av = ( - + var member = this.props.member; + var name = this._getDisplayName(); + var active = -1; + var presenceState = member.user ? member.user.presence : null; + + var av = ( + + ); + + 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 = ( - - ); - } - + this.member_last_modified_time = member.getLastModifiedTime(); + return ( -
-
- { av } - { power } -
- { nameEl } -
+ ); } }); diff --git a/src/components/views/rooms/SearchableEntityList.js b/src/components/views/rooms/SearchableEntityList.js new file mode 100644 index 0000000000..b6232362ac --- /dev/null +++ b/src/components/views/rooms/SearchableEntityList.js @@ -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 = ( +
+ +
+ ); + } + + return ( +
+ {inputBox} +
+ {this.state.results.map((entity) => { + return entity.getJsx(); + })} +
+
+ ); + } +}); + + module.exports = SearchableEntityList; diff --git a/src/components/views/rooms/UserTile.js b/src/components/views/rooms/UserTile.js new file mode 100644 index 0000000000..6597796764 --- /dev/null +++ b/src/components/views/rooms/UserTile.js @@ -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 = ( + + ); + + return ( + + ); + } +});