From ffd40c2c4081ce7bd2a6fb09a39e230f6d07b991 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 6 Jan 2020 20:51:23 -0700 Subject: [PATCH 1/8] Initial editor for user list selection For https://github.com/vector-im/riot-web/issues/11199 --- res/css/views/dialogs/_DMInviteDialog.scss | 77 ++++++- res/img/icon-email-pill-avatar.svg | 37 +++ res/img/icon-pill-remove.svg | 36 +++ .../views/dialogs/DMInviteDialog.js | 214 ++++++++++++++---- 4 files changed, 323 insertions(+), 41 deletions(-) create mode 100644 res/img/icon-email-pill-avatar.svg create mode 100644 res/img/icon-pill-remove.svg diff --git a/res/css/views/dialogs/_DMInviteDialog.scss b/res/css/views/dialogs/_DMInviteDialog.scss index 364c796f16..e581f9dc7a 100644 --- a/res/css/views/dialogs/_DMInviteDialog.scss +++ b/res/css/views/dialogs/_DMInviteDialog.scss @@ -21,15 +21,51 @@ limitations under the License. .mx_DMInviteDialog_editor { flex: 1; width: 100%; // Needed to make the Field inside grow - } + background-color: $user-tile-hover-bg-color; + border-radius: 4px; + min-height: 25px; + padding-left: 8px; + overflow-x: hidden; + overflow-y: auto; - .mx_Field { - margin: 0; + .mx_DMInviteDialog_userTile { + display: inline-block; + float: left; + position: relative; + top: 7px; + } + + // Using a textarea for this element, to circumvent autofill + // Mostly copied from AddressPickerDialog + textarea, + textarea:focus { + height: 34px; + line-height: 34px; + font-size: 14px; + padding-left: 12px; + margin: 0 !important; + border: 0 !important; + outline: 0 !important; + resize: none; + overflow: hidden; + box-sizing: border-box; + word-wrap: nowrap; + + // Roughly fill about 2/5ths of the available space. This is to try and 'fill' the + // remaining space after a bunch of pills, but is a bit hacky. Ideally we'd have + // support for "fill remaining width", but traditional tricks don't work with what + // we're pushing into this "field". Flexbox just makes things worse. The theory is + // that users won't need more than about 2/5ths of the input to find the person + // they're looking for. + width: 40%; + } } .mx_DMInviteDialog_goButton { width: 48px; margin-left: 10px; + height: 25px; + line-height: 25px; } } @@ -83,3 +119,38 @@ limitations under the License. } } +// Many of these styles are stolen from mx_UserPill, but adjusted for the invite dialog. +.mx_DMInviteDialog_userTile { + margin-right: 8px; + + .mx_DMInviteDialog_userTile_pill { + background-color: $username-variant1-color; + border-radius: 12px; + display: inline-block; + height: 24px; + line-height: 24px; + padding-left: 8px; + padding-right: 8px; + color: #ffffff; // this is fine without a var because it's for both themes + + .mx_DMInviteDialog_userTile_avatar { + border-radius: 20px; + position: relative; + left: -5px; + top: 2px; + } + + img.mx_DMInviteDialog_userTile_avatar { + vertical-align: top; + } + + .mx_DMInviteDialog_userTile_name { + vertical-align: top; + } + } + + .mx_DMInviteDialog_userTile_remove { + display: inline-block; + margin-left: 4px; + } +} diff --git a/res/img/icon-email-pill-avatar.svg b/res/img/icon-email-pill-avatar.svg new file mode 100644 index 0000000000..c107ccc480 --- /dev/null +++ b/res/img/icon-email-pill-avatar.svg @@ -0,0 +1,37 @@ + + + + at-sign + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/img/icon-pill-remove.svg b/res/img/icon-pill-remove.svg new file mode 100644 index 0000000000..5b31cca42f --- /dev/null +++ b/res/img/icon-pill-remove.svg @@ -0,0 +1,36 @@ + + + + x + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index c5e9c92131..6eb6d0c78b 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -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 + ? + : ; + + return ( + + + {avatar} + {this.props.member.name} + + + {_t('Remove')} + + + ); + } +} + 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 + ? + : ; return (
- + {avatar} {this._highlightName(this.props.member.name)} {this._highlightName(this.props.member.userId)} {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 => ( + + )); + const input = ( +