Merge pull request #3819 from matrix-org/travis/ftue/user-lists/4.5-3pids
Support 3PIDs (email addresses) in the invite dialog
This commit is contained in:
commit
ad33a2322e
3 changed files with 172 additions and 47 deletions
|
@ -184,6 +184,10 @@ limitations under the License.
|
||||||
.mx_DMInviteDialog_userTile_name {
|
.mx_DMInviteDialog_userTile_name {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_DMInviteDialog_userTile_threepidAvatar {
|
||||||
|
background-color: #ffffff; // this is fine without a var because it's for both themes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_DMInviteDialog_userTile_remove {
|
.mx_DMInviteDialog_userTile_remove {
|
||||||
|
|
|
@ -1,37 +1 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<svg xmlns="http://www.w3.org/2000/svg" width="65.631" height="67.981"><defs><filter x="-.059" y="-.079" width="1.118" height="1.158" filterUnits="objectBoundingBox" id="a"><feOffset dy="2" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur stdDeviation="16" in="shadowOffsetOuter1" result="shadowBlurOuter1"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0.473684211 0 0 0 0 1 0 0 0 0.241258741 0" in="shadowBlurOuter1" result="shadowMatrixOuter1"/><feMerge><feMergeNode in="shadowMatrixOuter1"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g filter="url(#a)" transform="matrix(3.40907 0 0 3.40907 -1493.716 -795.144)" fill="none" fill-rule="evenodd" stroke="#368bd6" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(441.5 237.5)"><circle r="2.286" cy="5.714" cx="6.286"/><path d="M8.571 3.429v2.857a1.714 1.714 0 103.429 0v-.572a5.714 5.714 0 10-2.24 4.537"/></g></g></svg>
|
||||||
<svg width="69px" height="68px" viewBox="0 0 69 68" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<!-- Generator: Sketch 58 (84663) - https://sketch.com -->
|
|
||||||
<title>at-sign</title>
|
|
||||||
<desc>Created with Sketch.</desc>
|
|
||||||
<defs>
|
|
||||||
<filter x="-5.9%" y="-7.9%" width="111.8%" height="115.8%" filterUnits="objectBoundingBox" id="filter-1">
|
|
||||||
<feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
|
||||||
<feGaussianBlur stdDeviation="16" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
|
|
||||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0.473684211 0 0 0 0 1 0 0 0 0.241258741 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
|
|
||||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<g id="FTUE" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<g id="FTUE---User-list-suggestions" transform="translate(-5161.000000, -1379.000000)" stroke="#368BD6">
|
|
||||||
<g id="Group-14-Copy-10" transform="translate(4748.000000, 1168.000000)">
|
|
||||||
<g id="Web-Copy-6" filter="url(#filter-1)">
|
|
||||||
<g id="modal-copy" transform="translate(253.000000, 118.000000)">
|
|
||||||
<g id="content" transform="translate(40.000000, 107.000000)">
|
|
||||||
<g id="Group-43">
|
|
||||||
<g id="Group-15-Copy-6" transform="translate(143.000000, 6.000000)">
|
|
||||||
<g id="at-sign" transform="translate(5.500000, 6.500000)">
|
|
||||||
<circle id="Oval" cx="6.28571429" cy="5.71428571" r="2.28571429"></circle>
|
|
||||||
<path d="M8.57142857,3.42857143 L8.57142857,6.28571429 C8.57142857,7.23248814 9.33894043,8 10.2857143,8 C11.2324881,8 12,7.23248814 12,6.28571429 L12,5.71428571 C11.9998328,3.05880261 10.1703625,0.75337961 7.58436487,0.149884297 C4.9983672,-0.453611015 2.33741804,0.803881013 1.16186053,3.18498476 C-0.0136969889,5.5660885 0.605971794,8.4432307 2.65750183,10.1292957 C4.70903186,11.8153606 7.65171364,11.8659623 9.76,10.2514286" id="Path"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 918 B |
|
@ -25,6 +25,11 @@ import {RoomMember} from "matrix-js-sdk/lib/matrix";
|
||||||
import * as humanize from "humanize";
|
import * as humanize from "humanize";
|
||||||
import SdkConfig from "../../../SdkConfig";
|
import SdkConfig from "../../../SdkConfig";
|
||||||
import {getHttpUriForMxc} from "matrix-js-sdk/lib/content-repo";
|
import {getHttpUriForMxc} from "matrix-js-sdk/lib/content-repo";
|
||||||
|
import * as Email from "../../../email";
|
||||||
|
import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils";
|
||||||
|
import {abbreviateUrl} from "../../../utils/UrlUtils";
|
||||||
|
import dis from "../../../dispatcher";
|
||||||
|
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||||
|
|
||||||
// TODO: [TravisR] Make this generic for all kinds of invites
|
// TODO: [TravisR] Make this generic for all kinds of invites
|
||||||
|
|
||||||
|
@ -82,6 +87,35 @@ class DirectoryMember extends Member {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ThreepidMember extends Member {
|
||||||
|
_id: string;
|
||||||
|
|
||||||
|
constructor(id: string) {
|
||||||
|
super();
|
||||||
|
this._id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a getter that would be falsey on all other implementations. Until we have
|
||||||
|
// better type support in the react-sdk we can use this trick to determine the kind
|
||||||
|
// of 3PID we're dealing with, if any.
|
||||||
|
get isEmail(): boolean {
|
||||||
|
return this._id.includes('@');
|
||||||
|
}
|
||||||
|
|
||||||
|
// These next class members are for the Member interface
|
||||||
|
get name(): string {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userId(): string {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMxcAvatarUrl(): string {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class DMUserTile extends React.PureComponent {
|
class DMUserTile extends React.PureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
|
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
|
||||||
|
@ -103,7 +137,7 @@ class DMUserTile extends React.PureComponent {
|
||||||
const avatarSize = 20;
|
const avatarSize = 20;
|
||||||
const avatar = this.props.member.isEmail
|
const avatar = this.props.member.isEmail
|
||||||
? <img
|
? <img
|
||||||
className='mx_DMInviteDialog_userTile_avatar'
|
className='mx_DMInviteDialog_userTile_avatar mx_DMInviteDialog_userTile_threepidAvatar'
|
||||||
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
|
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
|
||||||
width={avatarSize} height={avatarSize} />
|
width={avatarSize} height={avatarSize} />
|
||||||
: <BaseAvatar
|
: <BaseAvatar
|
||||||
|
@ -257,6 +291,9 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
suggestions: this._buildSuggestions(),
|
suggestions: this._buildSuggestions(),
|
||||||
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
|
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
|
||||||
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
|
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
|
||||||
|
threepidResultsMixin: [], // { user: ThreepidMember, userId: string}[], like recents and suggestions
|
||||||
|
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
|
||||||
|
tryingIdentityServer: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this._editorRef = createRef();
|
this._editorRef = createRef();
|
||||||
|
@ -360,7 +397,7 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
if (this._debounceTimer) {
|
if (this._debounceTimer) {
|
||||||
clearTimeout(this._debounceTimer);
|
clearTimeout(this._debounceTimer);
|
||||||
}
|
}
|
||||||
this._debounceTimer = setTimeout(() => {
|
this._debounceTimer = setTimeout(async () => {
|
||||||
MatrixClientPeg.get().searchUserDirectory({term}).then(r => {
|
MatrixClientPeg.get().searchUserDirectory({term}).then(r => {
|
||||||
if (term !== this.state.filterText) {
|
if (term !== this.state.filterText) {
|
||||||
// Discard the results - we were probably too slow on the server-side to make
|
// Discard the results - we were probably too slow on the server-side to make
|
||||||
|
@ -379,6 +416,62 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
|
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whenever we search the directory, also try to search the identity server. It's
|
||||||
|
// all debounced the same anyways.
|
||||||
|
if (!this.state.canUseIdentityServer) {
|
||||||
|
// The user doesn't have an identity server set - warn them of that.
|
||||||
|
this.setState({tryingIdentityServer: true});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (term.indexOf('@') > 0 && Email.looksValid(term)) {
|
||||||
|
// Start off by suggesting the plain email while we try and resolve it
|
||||||
|
// to a real account.
|
||||||
|
this.setState({
|
||||||
|
// per above: the userId is a lie here - it's just a regular identifier
|
||||||
|
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const authClient = new IdentityAuthClient();
|
||||||
|
const token = await authClient.getAccessToken();
|
||||||
|
if (term !== this.state.filterText) return; // abandon hope
|
||||||
|
|
||||||
|
const lookup = await MatrixClientPeg.get().lookupThreePid(
|
||||||
|
'email',
|
||||||
|
term,
|
||||||
|
undefined, // callback
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
if (term !== this.state.filterText) return; // abandon hope
|
||||||
|
|
||||||
|
if (!lookup || !lookup.mxid) {
|
||||||
|
// We weren't able to find anyone - we're already suggesting the plain email
|
||||||
|
// as an alternative, so do nothing.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We append the user suggestion to give the user an option to click
|
||||||
|
// the email anyways, and so we don't cause things to jump around. In
|
||||||
|
// theory, the user would see the user pop up and think "ah yes, that
|
||||||
|
// person!"
|
||||||
|
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
|
||||||
|
if (term !== this.state.filterText || !profile) return; // abandon hope
|
||||||
|
this.setState({
|
||||||
|
threepidResultsMixin: [...this.state.threepidResultsMixin, {
|
||||||
|
user: new DirectoryMember({
|
||||||
|
user_id: lookup.mxid,
|
||||||
|
display_name: profile.displayname,
|
||||||
|
avatar_url: profile.avatar_url,
|
||||||
|
}),
|
||||||
|
userId: lookup.mxid,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error searching identity server:");
|
||||||
|
console.error(e);
|
||||||
|
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
}, 150); // 150ms debounce (human reaction time + some)
|
}, 150); // 150ms debounce (human reaction time + some)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -417,6 +510,21 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_onUseDefaultIdentityServerClick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Update the IS in account data. Actually using it may trigger terms.
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
useDefaultIdentityServer();
|
||||||
|
this.setState({canUseIdentityServer: true, tryingIdentityServer: false});
|
||||||
|
};
|
||||||
|
|
||||||
|
_onManageSettingsClick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dis.dispatch({ action: 'view_user_settings' });
|
||||||
|
this._cancel();
|
||||||
|
};
|
||||||
|
|
||||||
_renderSection(kind: "recents"|"suggestions") {
|
_renderSection(kind: "recents"|"suggestions") {
|
||||||
let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions;
|
let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions;
|
||||||
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
|
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
|
||||||
|
@ -424,17 +532,27 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
const lastActive = (m) => kind === 'recents' ? m.lastActive : null;
|
const lastActive = (m) => kind === 'recents' ? m.lastActive : null;
|
||||||
const sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
|
const sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
|
||||||
|
|
||||||
// Mix in the server results if we have any, but only if we're searching
|
// Mix in the server results if we have any, but only if we're searching. We track the additional
|
||||||
if (this.state.filterText && this.state.serverResultsMixin && kind === 'suggestions') {
|
// members separately because we want to filter sourceMembers but trust the mixin arrays to have
|
||||||
// only pick out the server results that aren't already covered though
|
// the right members in them.
|
||||||
const uniqueServerResults = this.state.serverResultsMixin
|
let additionalMembers = [];
|
||||||
.filter(u => !sourceMembers.some(m => m.userId === u.userId));
|
const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin;
|
||||||
|
if (this.state.filterText && hasMixins && kind === 'suggestions') {
|
||||||
|
// We don't want to duplicate members though, so just exclude anyone we've already seen.
|
||||||
|
const notAlreadyExists = (u: Member): boolean => {
|
||||||
|
return !sourceMembers.some(m => m.userId === u.userId)
|
||||||
|
&& !additionalMembers.some(m => m.userId === u.userId);
|
||||||
|
};
|
||||||
|
|
||||||
sourceMembers = sourceMembers.concat(uniqueServerResults);
|
const uniqueServerResults = this.state.serverResultsMixin.filter(notAlreadyExists);
|
||||||
|
additionalMembers = additionalMembers.concat(...uniqueServerResults);
|
||||||
|
|
||||||
|
const uniqueThreepidResults = this.state.threepidResultsMixin.filter(notAlreadyExists);
|
||||||
|
additionalMembers = additionalMembers.concat(...uniqueThreepidResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the section if there's nothing to filter by
|
// Hide the section if there's nothing to filter by
|
||||||
if (!sourceMembers || sourceMembers.length === 0) return null;
|
if (sourceMembers.length === 0 && additionalMembers.length === 0) return null;
|
||||||
|
|
||||||
// Do some simple filtering on the input before going much further. If we get no results, say so.
|
// Do some simple filtering on the input before going much further. If we get no results, say so.
|
||||||
if (this.state.filterText) {
|
if (this.state.filterText) {
|
||||||
|
@ -442,7 +560,7 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
sourceMembers = sourceMembers
|
sourceMembers = sourceMembers
|
||||||
.filter(m => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy));
|
.filter(m => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy));
|
||||||
|
|
||||||
if (sourceMembers.length === 0) {
|
if (sourceMembers.length === 0 && additionalMembers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='mx_DMInviteDialog_section'>
|
<div className='mx_DMInviteDialog_section'>
|
||||||
<h3>{sectionName}</h3>
|
<h3>{sectionName}</h3>
|
||||||
|
@ -452,6 +570,10 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now we mix in the additional members. Again, we presume these have already been filtered. We
|
||||||
|
// also assume they are more relevant than our suggestions and prepend them to the list.
|
||||||
|
sourceMembers = [...additionalMembers, ...sourceMembers];
|
||||||
|
|
||||||
// If we're going to hide one member behind 'show more', just use up the space of the button
|
// If we're going to hide one member behind 'show more', just use up the space of the button
|
||||||
// with the member's tile instead.
|
// with the member's tile instead.
|
||||||
if (showNum === sourceMembers.length - 1) showNum++;
|
if (showNum === sourceMembers.length - 1) showNum++;
|
||||||
|
@ -510,6 +632,40 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderIdentityServerWarning() {
|
||||||
|
if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
|
||||||
|
if (defaultIdentityServerUrl) {
|
||||||
|
return (
|
||||||
|
<div className="mx_AddressPickerDialog_identityServer">{_t(
|
||||||
|
"Use an identity server to invite by email. " +
|
||||||
|
"<default>Use the default (%(defaultIdentityServerName)s)</default> " +
|
||||||
|
"or manage in <settings>Settings</settings>.",
|
||||||
|
{
|
||||||
|
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: sub => <a href="#" onClick={this._onUseDefaultIdentityServerClick}>{sub}</a>,
|
||||||
|
settings: sub => <a href="#" onClick={this._onManageSettingsClick}>{sub}</a>,
|
||||||
|
},
|
||||||
|
)}</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="mx_AddressPickerDialog_identityServer">{_t(
|
||||||
|
"Use an identity server to invite by email. " +
|
||||||
|
"Manage in <settings>Settings</settings>.",
|
||||||
|
{}, {
|
||||||
|
settings: sub => <a href="#" onClick={this._onManageSettingsClick}>{sub}</a>,
|
||||||
|
},
|
||||||
|
)}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||||
|
@ -533,6 +689,7 @@ export default class DMInviteDialog extends React.PureComponent {
|
||||||
</p>
|
</p>
|
||||||
<div className='mx_DMInviteDialog_addressBar'>
|
<div className='mx_DMInviteDialog_addressBar'>
|
||||||
{this._renderEditor()}
|
{this._renderEditor()}
|
||||||
|
{this._renderIdentityServerWarning()}
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
kind="primary"
|
kind="primary"
|
||||||
onClick={this._startDm}
|
onClick={this._startDm}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue