element-web/src/components/views/dialogs/AddressPickerDialog.js

585 lines
21 KiB
JavaScript
Raw Normal View History

/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector 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.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
2017-01-25 01:41:52 +03:00
import AccessibleButton from '../elements/AccessibleButton';
import Promise from 'bluebird';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import GroupStoreCache from '../../../stores/GroupStoreCache';
2016-09-06 17:46:58 +03:00
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
2016-09-05 19:28:08 +03:00
module.exports = React.createClass({
displayName: "AddressPickerDialog",
propTypes: {
title: PropTypes.string.isRequired,
2017-08-15 12:57:24 +03:00
description: PropTypes.node,
value: PropTypes.string,
placeholder: PropTypes.string,
roomId: PropTypes.string,
button: PropTypes.string,
focus: PropTypes.bool,
validAddressTypes: PropTypes.arrayOf(PropTypes.oneOf(addressTypes)),
onFinished: PropTypes.func.isRequired,
groupId: PropTypes.string,
// The type of entity to search for. Default: 'user'.
pickerType: PropTypes.oneOf(['user', 'room']),
2017-09-27 13:52:05 +03:00
// Whether the current user should be included in the addresses returned. Only
// applicable when pickerType is `user`. Default: false.
includeSelf: PropTypes.bool,
},
getDefaultProps: function() {
return {
value: "",
focus: true,
validAddressTypes: addressTypes,
pickerType: 'user',
2017-09-27 13:52:05 +03:00
includeSelf: false,
};
},
2016-09-05 19:28:08 +03:00
getInitialState: function() {
return {
2016-09-13 13:02:59 +03:00
error: false,
2017-01-25 18:23:50 +03:00
2017-08-15 12:57:24 +03:00
// List of UserAddressType objects representing
2017-01-25 18:23:50 +03:00
// the list of addresses we're going to invite
2017-08-15 15:30:13 +03:00
userList: [],
2017-01-25 18:23:50 +03:00
// Whether a search is ongoing
busy: false,
// An error message generated during the user directory search
searchError: null,
// Whether the server supports the user_directory API
serverSupportsUserDirectory: true,
// The query being searched for
query: "",
2017-08-15 12:57:24 +03:00
// List of UserAddressType objects representing
// the set of auto-completion results for the current search
2017-01-25 18:23:50 +03:00
// query.
queryList: [],
2016-09-05 19:28:08 +03:00
};
},
componentDidMount: function() {
if (this.props.focus) {
// Set the cursor at the end of the text input
this.refs.textinput.value = this.props.value;
}
},
onButtonClick: function() {
2017-08-15 15:30:13 +03:00
let userList = this.state.userList.slice();
// Check the text input field to see if user has an unconverted address
2017-08-15 15:30:13 +03:00
// If there is and it's valid add it to the local userList
if (this.refs.textinput.value !== '') {
2017-08-15 15:30:13 +03:00
userList = this._addInputToList();
if (userList === null) return;
}
2017-08-15 15:30:13 +03:00
this.props.onFinished(true, userList);
},
2016-09-05 19:28:08 +03:00
onCancel: function() {
this.props.onFinished(false);
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
2016-09-06 15:07:06 +03:00
} else if (e.keyCode === 38) { // up arrow
2016-09-05 19:28:08 +03:00
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionUp();
2016-09-06 15:07:06 +03:00
} else if (e.keyCode === 40) { // down arrow
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionDown();
} else if (this.state.queryList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab
2016-09-06 15:07:06 +03:00
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.chooseSelection();
2017-08-15 15:30:13 +03:00
} else if (this.refs.textinput.value.length === 0 && this.state.userList.length && e.keyCode === 8) { // backspace
e.stopPropagation();
e.preventDefault();
2017-08-15 15:30:13 +03:00
this.onDismissed(this.state.userList.length - 1)();
} else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
if (this.refs.textinput.value == '') {
// if there's nothing in the input box, submit the form
this.onButtonClick();
} else {
this._addInputToList();
}
} else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab
e.stopPropagation();
e.preventDefault();
this._addInputToList();
2016-09-05 19:28:08 +03:00
}
},
onQueryChanged: function(ev) {
const query = ev.target.value;
if (this.queryChangedDebouncer) {
clearTimeout(this.queryChangedDebouncer);
}
// Only do search if there is something to search
if (query.length > 0 && query != '@' && query.length >= 2) {
this.queryChangedDebouncer = setTimeout(() => {
if (this.props.pickerType === 'user') {
if (this.props.groupId) {
this._doNaiveGroupSearch(query);
} else if (this.state.serverSupportsUserDirectory) {
this._doUserDirectorySearch(query);
} else {
this._doLocalSearch(query);
}
} else if (this.props.pickerType === 'room') {
if (this.props.groupId) {
this._doNaiveGroupRoomSearch(query);
} else {
2017-09-26 16:49:13 +03:00
this._doRoomSearch(query);
}
} else {
console.error('Unknown pickerType', this.props.pickerType);
}
}, QUERY_USER_DIRECTORY_DEBOUNCE_MS);
} else {
this.setState({
queryList: [],
query: "",
searchError: null,
});
}
2016-09-05 19:28:08 +03:00
},
onDismissed: function(index) {
return () => {
2017-08-15 15:30:13 +03:00
const userList = this.state.userList.slice();
userList.splice(index, 1);
this.setState({
2017-08-15 15:30:13 +03:00
userList: userList,
queryList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
};
2016-09-05 19:28:08 +03:00
},
onClick: function(index) {
return () => {
this.onSelected(index);
};
},
onSelected: function(index) {
2017-08-15 15:30:13 +03:00
const userList = this.state.userList.slice();
userList.push(this.state.queryList[index]);
this.setState({
2017-08-15 15:30:13 +03:00
userList: userList,
queryList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
},
_doNaiveGroupSearch: function(query) {
const lowerCaseQuery = query.toLowerCase();
this.setState({
busy: true,
query,
searchError: null,
});
MatrixClientPeg.get().getGroupUsers(this.props.groupId).then((resp) => {
const results = [];
resp.chunk.forEach((u) => {
const userIdMatch = u.user_id.toLowerCase().includes(lowerCaseQuery);
const displayNameMatch = (u.displayname || '').toLowerCase().includes(lowerCaseQuery);
if (!(userIdMatch || displayNameMatch)) {
return;
}
results.push({
user_id: u.user_id,
avatar_url: u.avatar_url,
display_name: u.displayname,
});
});
this._processResults(results, query);
}).catch((err) => {
console.error('Error whilst searching group rooms: ', err);
this.setState({
searchError: err.errcode ? err.message : _t('Something went wrong!'),
});
}).done(() => {
this.setState({
busy: false,
});
});
},
_doNaiveGroupRoomSearch: function(query) {
const lowerCaseQuery = query.toLowerCase();
const groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), this.props.groupId);
const results = [];
groupStore.getGroupRooms().forEach((r) => {
const nameMatch = (r.name || '').toLowerCase().includes(lowerCaseQuery);
const topicMatch = (r.topic || '').toLowerCase().includes(lowerCaseQuery);
const aliasMatch = (r.canonical_alias || '').toLowerCase().includes(lowerCaseQuery);
if (!(nameMatch || topicMatch || aliasMatch)) {
return;
}
results.push({
room_id: r.room_id,
avatar_url: r.avatar_url,
name: r.name || r.canonical_alias,
});
});
this._processResults(results, query);
this.setState({
busy: false,
});
},
2017-09-26 16:49:13 +03:00
_doRoomSearch: function(query) {
const lowerCaseQuery = query.toLowerCase();
const rooms = MatrixClientPeg.get().getRooms();
const results = [];
rooms.forEach((room) => {
const nameEvent = room.currentState.getStateEvents('m.room.name', '');
const topicEvent = room.currentState.getStateEvents('m.room.topic', '');
const name = nameEvent ? nameEvent.getContent().name : '';
const canonicalAlias = room.getCanonicalAlias();
const topic = topicEvent ? topicEvent.getContent().topic : '';
const nameMatch = (name || '').toLowerCase().includes(lowerCaseQuery);
const aliasMatch = (canonicalAlias || '').toLowerCase().includes(lowerCaseQuery);
const topicMatch = (topic || '').toLowerCase().includes(lowerCaseQuery);
if (!(nameMatch || topicMatch || aliasMatch)) {
return;
}
const avatarEvent = room.currentState.getStateEvents('m.room.avatar', '');
const avatarUrl = avatarEvent ? avatarEvent.getContent().url : undefined;
const aliasEvents = room.currentState.getStateEvents('m.room.aliases');
const aliases = aliasEvents.map((ev) => ev.getContent().aliases).reduce((a, b) => {
return a.concat(b);
}, []);
results.push({
room_id: room.roomId,
avatar_url: avatarUrl,
name: name || canonicalAlias || aliases[0] || _t('Unnamed Room'),
2017-09-26 16:49:13 +03:00
});
});
this._processResults(results, query);
this.setState({
busy: false,
});
2017-09-26 16:49:13 +03:00
},
_doUserDirectorySearch: function(query) {
this.setState({
busy: true,
query,
searchError: null,
});
MatrixClientPeg.get().searchUserDirectory({
term: query,
}).then((resp) => {
// The query might have changed since we sent the request, so ignore
// responses for anything other than the latest query.
if (this.state.query !== query) {
return;
}
this._processResults(resp.results, query);
}).catch((err) => {
console.error('Error whilst searching user directory: ', err);
this.setState({
searchError: err.errcode ? err.message : _t('Something went wrong!'),
});
if (err.errcode === 'M_UNRECOGNIZED') {
this.setState({
serverSupportsUserDirectory: false,
});
// Do a local search immediately
this._doLocalSearch(query);
}
}).done(() => {
this.setState({
busy: false,
});
});
},
_doLocalSearch: function(query) {
this.setState({
query,
searchError: null,
});
const queryLowercase = query.toLowerCase();
const results = [];
MatrixClientPeg.get().getUsers().forEach((user) => {
if (user.userId.toLowerCase().indexOf(queryLowercase) === -1 &&
user.displayName.toLowerCase().indexOf(queryLowercase) === -1
) {
return;
}
// Put results in the format of the new API
results.push({
user_id: user.userId,
display_name: user.displayName,
avatar_url: user.avatarUrl,
});
});
this._processResults(results, query);
},
_processResults: function(results, query) {
const queryList = [];
results.forEach((result) => {
if (result.room_id) {
queryList.push({
addressType: 'mx-room-id',
address: result.room_id,
displayName: result.name,
avatarMxc: result.avatar_url,
isKnown: true,
});
return;
}
2017-09-27 13:52:05 +03:00
if (!this.props.includeSelf &&
result.user_id === MatrixClientPeg.get().credentials.userId
) {
return;
}
// Return objects, structure of which is defined
2017-08-15 12:57:24 +03:00
// by UserAddressType
queryList.push({
addressType: 'mx-user-id',
address: result.user_id,
displayName: result.display_name,
avatarMxc: result.avatar_url,
isKnown: true,
});
});
// If the query is a valid address, add an entry for that
// This is important, otherwise there's no way to invite
// a perfectly valid address if there are close matches.
const addrType = getAddressType(query);
2017-08-15 12:57:24 +03:00
if (this.props.validAddressTypes.includes(addrType)) {
queryList.unshift({
addressType: addrType,
address: query,
isKnown: false,
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (addrType == 'email') {
this._lookupThreepid(addrType, query).done();
}
}
this.setState({
queryList,
error: false,
}, () => {
if (this.addressSelector) this.addressSelector.moveSelectionTop();
});
},
_addInputToList: function() {
const addressText = this.refs.textinput.value.trim();
const addrType = getAddressType(addressText);
const addrObj = {
addressType: addrType,
address: addressText,
isKnown: false,
};
if (!this.props.validAddressTypes.includes(addrType)) {
this.setState({ error: true });
2017-01-19 21:13:27 +03:00
return null;
} else if (addrType == 'mx-user-id') {
const user = MatrixClientPeg.get().getUser(addrObj.address);
if (user) {
addrObj.displayName = user.displayName;
addrObj.avatarMxc = user.avatarUrl;
addrObj.isKnown = true;
}
} else if (addrType == 'mx-room-id') {
const room = MatrixClientPeg.get().getRoom(addrObj.address);
if (room) {
addrObj.displayName = room.name;
addrObj.avatarMxc = room.avatarUrl;
addrObj.isKnown = true;
}
}
2017-08-15 15:30:13 +03:00
const userList = this.state.userList.slice();
userList.push(addrObj);
this.setState({
2017-08-15 15:30:13 +03:00
userList: userList,
queryList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
2017-08-15 15:30:13 +03:00
return userList;
},
2017-01-26 13:09:33 +03:00
_lookupThreepid: function(medium, address) {
let cancelled = false;
// Note that we can't safely remove this after we're done
// because we don't know that it's the same one, so we just
// leave it: it's replacing the old one each time so it's
// not like they leak.
this._cancelThreepidLookup = function() {
cancelled = true;
2017-08-15 11:10:13 +03:00
};
// wait a bit to let the user finish typing
return Promise.delay(500).then(() => {
if (cancelled) return null;
return MatrixClientPeg.get().lookupThreePid(medium, address);
}).then((res) => {
if (res === null || !res.mxid) return null;
if (cancelled) return null;
return MatrixClientPeg.get().getProfileInfo(res.mxid);
}).then((res) => {
if (res === null) return null;
if (cancelled) return null;
2017-01-26 13:08:44 +03:00
this.setState({
queryList: [{
2017-08-15 12:57:24 +03:00
// a UserAddressType
2017-01-26 13:08:44 +03:00
addressType: medium,
address: address,
displayName: res.displayname,
avatarMxc: res.avatar_url,
isKnown: true,
}],
2017-01-26 13:08:44 +03:00
});
});
},
render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const AddressSelector = sdk.getComponent("elements.AddressSelector");
2016-09-06 19:46:00 +03:00
this.scrollElement = null;
2016-09-05 19:28:08 +03:00
2017-08-15 11:10:13 +03:00
const query = [];
// create the invite list
2017-08-15 15:30:13 +03:00
if (this.state.userList.length > 0) {
const AddressTile = sdk.getComponent("elements.AddressTile");
2017-08-15 15:30:13 +03:00
for (let i = 0; i < this.state.userList.length; i++) {
query.push(
2017-10-17 20:02:35 +03:00
<AddressTile
key={i}
address={this.state.userList[i]}
canDismiss={true}
onDismissed={this.onDismissed(i)}
showAddress={this.props.pickerType === 'user'} />,
);
}
2016-09-05 19:28:08 +03:00
}
2016-09-13 13:02:59 +03:00
// Add the query at the end
query.push(
2017-08-15 15:30:13 +03:00
<textarea key={this.state.userList.length}
rows="1"
id="textinput"
ref="textinput"
className="mx_ChatInviteDialog_input"
onChange={this.onQueryChanged}
placeholder={this.props.placeholder}
defaultValue={this.props.value}
autoFocus={this.props.focus}>
</textarea>,
);
2016-09-05 19:28:08 +03:00
let error;
let addressSelector;
2016-09-13 13:02:59 +03:00
if (this.state.error) {
let tryUsing = '';
const validTypeDescriptions = this.props.validAddressTypes.map((t) => {
return {
'mx-user-id': _t("Matrix ID"),
'mx-room-id': _t("Matrix Room ID"),
'email': _t("email address"),
}[t];
});
tryUsing = _t("Try using one of the following valid address types: %(validTypesList)s.", {
validTypesList: validTypeDescriptions.join(", "),
});
2017-08-15 11:10:13 +03:00
error = <div className="mx_ChatInviteDialog_error">
{ _t("You have entered an invalid address.") }
<br />
{ tryUsing }
2017-08-15 11:10:13 +03:00
</div>;
} else if (this.state.searchError) {
error = <div className="mx_ChatInviteDialog_error">{ this.state.searchError }</div>;
} else if (
this.state.query.length > 0 &&
this.state.queryList.length === 0 &&
!this.state.busy
) {
error = <div className="mx_ChatInviteDialog_error">{ _t("No results") }</div>;
2016-09-13 13:02:59 +03:00
} else {
addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}}
addressList={this.state.queryList}
2017-10-17 20:02:35 +03:00
showAddress={this.props.pickerType === 'user'}
onSelected={this.onSelected}
truncateAt={TRUNCATE_QUERY_LIST}
/>
2016-09-13 13:02:59 +03:00
);
}
return (
<div className="mx_ChatInviteDialog" onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_title">
{ this.props.title }
</div>
2017-01-25 01:41:52 +03:00
<AccessibleButton className="mx_ChatInviteDialog_cancel"
onClick={this.onCancel} >
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
2017-01-13 19:25:26 +03:00
</AccessibleButton>
<div className="mx_ChatInviteDialog_label">
<label htmlFor="textinput">{ this.props.description }</label>
</div>
<div className="mx_Dialog_content">
<div className="mx_ChatInviteDialog_inputContainer">{ query }</div>
2016-09-13 13:02:59 +03:00
{ error }
{ addressSelector }
</div>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.onButtonClick}>
{ this.props.button }
</button>
</div>
</div>
);
2017-08-15 11:10:13 +03:00
},
});