Merge pull request #3815 from matrix-org/travis/ftue/user-lists/4-composer

Wire up the invite targets dialog to a real composer and show selections
pull/21833/head
Travis Ralston 2020-01-09 13:57:22 -07:00 committed by GitHub
commit ba73600cf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 312 additions and 42 deletions

View File

@ -21,15 +21,51 @@ limitations under the License.
.mx_DMInviteDialog_editor { .mx_DMInviteDialog_editor {
flex: 1; flex: 1;
width: 100%; // Needed to make the Field inside grow 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_DMInviteDialog_userTile {
display: inline-block;
float: left;
position: relative;
top: 7px;
} }
.mx_Field { // Using a textarea for this element, to circumvent autofill
margin: 0; // 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 { .mx_DMInviteDialog_goButton {
width: 48px; width: 48px;
margin-left: 10px; margin-left: 10px;
height: 25px;
line-height: 25px;
} }
} }
@ -57,6 +93,43 @@ limitations under the License.
vertical-align: middle; vertical-align: middle;
} }
.mx_DMInviteDialog_roomTile_avatarStack {
display: inline-block;
position: relative;
width: 36px;
height: 36px;
& > * {
position: absolute;
top: 0;
left: 0;
}
}
.mx_DMInviteDialog_roomTile_selected {
width: 36px;
height: 36px;
border-radius: 36px;
background-color: $username-variant1-color;
display: inline-block;
position: relative;
&::before {
content: "";
width: 24px;
height: 24px;
grid-column: 1;
grid-row: 1;
mask-image: url('$(res)/img/feather-customised/check.svg');
mask-size: 100%;
mask-repeat: no-repeat;
position: absolute;
top: 6px; // 50%
left: 6px; // 50%
background-color: #ffffff; // this is fine without a var because it's for both themes
}
}
.mx_DMInviteDialog_roomTile_name { .mx_DMInviteDialog_roomTile_name {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
@ -83,3 +156,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;
}
}

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<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>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1 @@
<svg width="58" height="60" viewBox="26 25 6 6" xmlns="http://www.w3.org/2000/svg"><defs><filter x="-5.9%" y="-7.9%" width="111.8%" height="115.8%" 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="translate(-406 -215)" stroke="#61708B"><path d="M438 240l-6 6M432 240l6 6"/></g></svg>

After

Width:  |  Height:  |  Size: 693 B

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import sdk from "../../../index"; import sdk from "../../../index";
@ -31,18 +31,44 @@ import {getHttpUriForMxc} from "matrix-js-sdk/lib/content-repo";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first 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 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.
//
// XXX: We should use TypeScript interfaces instead of this weird "abstract" class.
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; _userId: string;
_displayName: string; _displayName: string;
_avatarUrl: string; _avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) { constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
super();
this._userId = userDirResult.user_id; this._userId = userDirResult.user_id;
this._displayName = userDirResult.display_name; this._displayName = userDirResult.display_name;
this._avatarUrl = userDirResult.avatar_url; 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 { get name(): string {
return this._displayName || this._userId; return this._displayName || this._userId;
} }
@ -56,13 +82,64 @@ class DirectoryMember {
} }
} }
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 { class DMRoomTile extends React.PureComponent {
static propTypes = { static propTypes = {
// Has properties to match RoomMember: userId (str), name (str), getMxcAvatarUrl(): string member: PropTypes.object.isRequired, // Should be a Member (see interface above)
member: PropTypes.object.isRequired,
lastActiveTs: PropTypes.number, lastActiveTs: PropTypes.number,
onToggle: PropTypes.func.isRequired, onToggle: PropTypes.func.isRequired, // takes 1 argument, the member being toggled
highlightWord: PropTypes.string, highlightWord: PropTypes.string,
isSelected: PropTypes.bool,
}; };
_onClick = (e) => { _onClick = (e) => {
@ -70,7 +147,7 @@ class DMRoomTile extends React.PureComponent {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.props.onToggle(this.props.member.userId); this.props.onToggle(this.props.member);
}; };
_highlightName(str: string) { _highlightName(str: string) {
@ -121,19 +198,37 @@ class DMRoomTile extends React.PureComponent {
} }
const avatarSize = 36; const avatarSize = 36;
const avatarUrl = getHttpUriForMxc( 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(), MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(),
avatarSize, avatarSize, "crop"); avatarSize, avatarSize, "crop")}
return (
<div className='mx_DMInviteDialog_roomTile' onClick={this._onClick}>
<BaseAvatar
url={avatarUrl}
name={this.props.member.name} name={this.props.member.name}
idName={this.props.member.userId} idName={this.props.member.userId}
width={avatarSize} width={avatarSize}
height={avatarSize} height={avatarSize} />;
/>
let checkmark = null;
if (this.props.isSelected) {
// To reduce flickering we put the 'selected' room tile above the real avatar
checkmark = <div className='mx_DMInviteDialog_roomTile_selected' />;
}
// To reduce flickering we put the checkmark on top of the actual avatar (prevents
// the browser from reloading the image source when the avatar remounts).
const stackedAvatar = (
<span className='mx_DMInviteDialog_roomTile_avatarStack'>
{avatar}
{checkmark}
</span>
);
return (
<div className='mx_DMInviteDialog_roomTile' onClick={this._onClick}>
{stackedAvatar}
<span className='mx_DMInviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</span> <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> <span className='mx_DMInviteDialog_roomTile_userId'>{this._highlightName(this.props.member.userId)}</span>
{timestamp} {timestamp}
@ -149,12 +244,13 @@ export default class DMInviteDialog extends React.PureComponent {
}; };
_debounceTimer: number = null; _debounceTimer: number = null;
_editorRef: any = null;
constructor() { constructor() {
super(); super();
this.state = { this.state = {
targets: [], // string[] of mxids/email addresses targets: [], // array of Member objects (see interface above)
filterText: "", filterText: "",
recents: this._buildRecents(), recents: this._buildRecents(),
numRecentsShown: INITIAL_ROOMS_SHOWN, numRecentsShown: INITIAL_ROOMS_SHOWN,
@ -162,6 +258,8 @@ export default class DMInviteDialog extends React.PureComponent {
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
}; };
this._editorRef = createRef();
} }
_buildRecents(): {userId: string, user: RoomMember, lastActive: number} { _buildRecents(): {userId: string, user: RoomMember, lastActive: number} {
@ -245,7 +343,7 @@ export default class DMInviteDialog extends React.PureComponent {
} }
_startDm = () => { _startDm = () => {
this.props.onFinished(this.state.targets); this.props.onFinished(this.state.targets.map(t => t.userId));
}; };
_cancel = () => { _cancel = () => {
@ -292,14 +390,33 @@ export default class DMInviteDialog extends React.PureComponent {
this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN}); 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 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); if (idx >= 0) targets.splice(idx, 1);
else targets.push(userId); else targets.push(member);
this.setState({targets}); 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") { _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;
@ -360,6 +477,7 @@ export default class DMInviteDialog extends React.PureComponent {
key={r.userId} key={r.userId}
onToggle={this._toggleMember} onToggle={this._toggleMember}
highlightWord={this.state.filterText} highlightWord={this.state.filterText}
isSelected={this.state.targets.some(t => t.userId === r.userId)}
/> />
)); ));
return ( return (
@ -371,23 +489,30 @@ export default class DMInviteDialog extends React.PureComponent {
); );
} }
render() { _renderEditor() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const targets = this.state.targets.map(t => (
const Field = sdk.getComponent("elements.Field"); <DMUserTile member={t} onRemove={this._removeMember} key={t.userId} />
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); ));
const input = (
// Dev note: The use of Field is temporary/incomplete pending https://github.com/vector-im/riot-web/issues/11197 <textarea
// For now, we just list who the targets are. key={"input"}
const editor = ( rows={1}
<div className='mx_DMInviteDialog_editor'>
<Field
id="inviteTargets"
value={this.state.filterText}
onChange={this._updateFilter} onChange={this._updateFilter}
defaultValue={this.state.filterText}
ref={this._editorRef}
/> />
);
return (
<div className='mx_DMInviteDialog_editor' onClick={this._onClickInputArea}>
{targets}
{input}
</div> </div>
); );
const targets = this.state.targets.map(t => <div key={t}>{t}</div>); }
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const userId = MatrixClientPeg.get().getUserId(); const userId = MatrixClientPeg.get().getUserId();
return ( return (
@ -406,9 +531,8 @@ export default class DMInviteDialog extends React.PureComponent {
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>}, {a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>},
)} )}
</p> </p>
{targets}
<div className='mx_DMInviteDialog_addressBar'> <div className='mx_DMInviteDialog_addressBar'>
{editor} {this._renderEditor()}
<AccessibleButton <AccessibleButton
kind="primary" kind="primary"
onClick={this._startDm} onClick={this._startDm}