Initial editor for user list selection

For https://github.com/vector-im/riot-web/issues/11199
This commit is contained in:
Travis Ralston 2020-01-06 20:51:23 -07:00
parent 87a6791eb8
commit ffd40c2c40
4 changed files with 323 additions and 41 deletions

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import sdk from "../../../index";
@ -31,18 +31,46 @@ import {getHttpUriForMxc} from "matrix-js-sdk/lib/content-repo";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
class DirectoryMember {
// This is the interface that is expected by various components in this file. It is a bit
// awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses.
//
// Dev note: In order to allow us to compile the app correctly, this needs to be a class
// even though FlowJS supports interfaces. It just means that we "extend" rather than "implement"
// in the classes, at least until TypeScript saves us.
class Member {
/**
* The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email).
*/
get name(): string { throw new Error("Member class not implemented"); }
/**
* The ID of this Member. For users this should be their user ID. For 3PIDs this should
* be the 3PID address (email).
*/
get userId(): string { throw new Error("Member class not implemented"); }
/**
* Gets the MXC URL of this Member's avatar. For users this should be their profile's
* avatar MXC URL or null if none set. For 3PIDs this should always be null.
*/
getMxcAvatarUrl(): string { throw new Error("Member class not implemented"); }
}
class DirectoryMember extends Member {
_userId: string;
_displayName: string;
_avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
super();
this._userId = userDirResult.user_id;
this._displayName = userDirResult.display_name;
this._avatarUrl = userDirResult.avatar_url;
}
// These next members are to implement the contract expected by DMRoomTile
// These next class members are for the Member interface
get name(): string {
return this._displayName || this._userId;
}
@ -56,12 +84,91 @@ class DirectoryMember {
}
}
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 {
static propTypes = {
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
onRemove: PropTypes.func.isRequired, // takes 1 argument, the member being removed
};
_onRemove = (e) => {
// Stop the browser from highlighting text
e.preventDefault();
e.stopPropagation();
this.props.onRemove(this.props.member);
};
render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const avatarSize = 20;
const avatar = this.props.member.isEmail
? <img
className='mx_DMInviteDialog_userTile_avatar'
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} />
: <BaseAvatar
className='mx_DMInviteDialog_userTile_avatar'
url={getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(),
avatarSize, avatarSize, "crop")}
name={this.props.member.name}
idName={this.props.member.userId}
width={avatarSize}
height={avatarSize} />;
return (
<span className='mx_DMInviteDialog_userTile'>
<span className='mx_DMInviteDialog_userTile_pill'>
{avatar}
<span className='mx_DMInviteDialog_userTile_name'>{this.props.member.name}</span>
</span>
<AccessibleButton
className='mx_DMInviteDialog_userTile_remove'
onClick={this._onRemove}
>
<img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
</AccessibleButton>
</span>
);
}
}
class DMRoomTile extends React.PureComponent {
static propTypes = {
// Has properties to match RoomMember: userId (str), name (str), getMxcAvatarUrl(): string
member: PropTypes.object.isRequired,
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
lastActiveTs: PropTypes.number,
onToggle: PropTypes.func.isRequired,
onToggle: PropTypes.func.isRequired, // takes 1 argument, the member being toggled
highlightWord: PropTypes.string,
};
@ -70,7 +177,7 @@ class DMRoomTile extends React.PureComponent {
e.preventDefault();
e.stopPropagation();
this.props.onToggle(this.props.member.userId);
this.props.onToggle(this.props.member);
};
_highlightName(str: string) {
@ -121,19 +228,22 @@ class DMRoomTile extends React.PureComponent {
}
const avatarSize = 36;
const avatarUrl = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(),
avatarSize, avatarSize, "crop");
const avatar = this.props.member.isEmail
? <img
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} />
: <BaseAvatar
url={getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(),
avatarSize, avatarSize, "crop")}
name={this.props.member.name}
idName={this.props.member.userId}
width={avatarSize}
height={avatarSize} />;
return (
<div className='mx_DMInviteDialog_roomTile' onClick={this._onClick}>
<BaseAvatar
url={avatarUrl}
name={this.props.member.name}
idName={this.props.member.userId}
width={avatarSize}
height={avatarSize}
/>
{avatar}
<span className='mx_DMInviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</span>
<span className='mx_DMInviteDialog_roomTile_userId'>{this._highlightName(this.props.member.userId)}</span>
{timestamp}
@ -149,12 +259,13 @@ export default class DMInviteDialog extends React.PureComponent {
};
_debounceTimer: number = null;
_editorRef: any = null;
constructor() {
super();
this.state = {
targets: [], // string[] of mxids/email addresses
targets: [], // array of Member objects (see interface above)
filterText: "",
recents: this._buildRecents(),
numRecentsShown: INITIAL_ROOMS_SHOWN,
@ -162,6 +273,8 @@ export default class DMInviteDialog extends React.PureComponent {
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
};
this._editorRef = createRef();
}
_buildRecents(): {userId: string, user: RoomMember, lastActive: number} {
@ -245,7 +358,7 @@ export default class DMInviteDialog extends React.PureComponent {
}
_startDm = () => {
this.props.onFinished(this.state.targets);
this.props.onFinished(this.state.targets.map(t => t.userId));
};
_cancel = () => {
@ -292,14 +405,33 @@ export default class DMInviteDialog extends React.PureComponent {
this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN});
};
_toggleMember = (userId) => {
_toggleMember = (member: Member) => {
const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(userId);
const idx = targets.indexOf(member);
if (idx >= 0) targets.splice(idx, 1);
else targets.push(userId);
else targets.push(member);
this.setState({targets});
};
_removeMember = (member: Member) => {
const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member);
if (idx >= 0) {
targets.splice(idx, 1);
this.setState({targets});
}
};
_onClickInputArea = (e) => {
// Stop the browser from highlighting text
e.preventDefault();
e.stopPropagation();
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
}
};
_renderSection(kind: "recents"|"suggestions") {
let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions;
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
@ -371,24 +503,31 @@ export default class DMInviteDialog extends React.PureComponent {
);
}
_renderEditor() {
const targets = this.state.targets.map(t => (
<DMUserTile member={t} onRemove={this._removeMember} key={t.userId} />
));
const input = (
<textarea
key={"input"}
rows={1}
onChange={this._updateFilter}
defaultValue={this.state.filterText}
ref={this._editorRef}
/>
);
return (
<div className='mx_DMInviteDialog_editor' onClick={this._onClickInputArea}>
{targets}
{input}
</div>
)
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const Field = sdk.getComponent("elements.Field");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
// Dev note: The use of Field is temporary/incomplete pending https://github.com/vector-im/riot-web/issues/11197
// For now, we just list who the targets are.
const editor = (
<div className='mx_DMInviteDialog_editor'>
<Field
id="inviteTargets"
value={this.state.filterText}
onChange={this._updateFilter}
/>
</div>
);
const targets = this.state.targets.map(t => <div key={t}>{t}</div>);
const userId = MatrixClientPeg.get().getUserId();
return (
<BaseDialog
@ -406,9 +545,8 @@ export default class DMInviteDialog extends React.PureComponent {
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>},
)}
</p>
{targets}
<div className='mx_DMInviteDialog_addressBar'>
{editor}
{this._renderEditor()}
<AccessibleButton
kind="primary"
onClick={this._startDm}