Merge pull request #3802 from matrix-org/travis/ftue/user-lists/3-filtering

Support filtering and searching for users to invite in DMs
This commit is contained in:
Travis Ralston 2020-01-06 13:38:32 -07:00 committed by GitHub
commit b44e4ab45c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 150 additions and 9 deletions

View file

@ -77,5 +77,9 @@ limitations under the License.
float: right; float: right;
line-height: 36px; // Height of the avatar to keep the time vertically aligned line-height: 36px; // Height of the avatar to keep the time vertically aligned
} }
.mx_DMInviteDialog_roomTile_highlight {
font-weight: 900;
}
} }

View file

@ -24,17 +24,45 @@ import DMRoomMap from "../../../utils/DMRoomMap";
import {RoomMember} from "matrix-js-sdk/lib/matrix"; 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";
// TODO: [TravisR] Make this generic for all kinds of invites // TODO: [TravisR] Make this generic for all kinds of invites
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 {
_userId: string;
_displayName: string;
_avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
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
get name(): string {
return this._displayName || this._userId;
}
get userId(): string {
return this._userId;
}
getMxcAvatarUrl(): string {
return this._avatarUrl;
}
}
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, member: PropTypes.object.isRequired,
lastActiveTs: PropTypes.number, lastActiveTs: PropTypes.number,
onToggle: PropTypes.func.isRequired, onToggle: PropTypes.func.isRequired,
highlightWord: PropTypes.string,
}; };
_onClick = (e) => { _onClick = (e) => {
@ -45,8 +73,44 @@ class DMRoomTile extends React.PureComponent {
this.props.onToggle(this.props.member.userId); this.props.onToggle(this.props.member.userId);
}; };
_highlightName(str: string) {
if (!this.props.highlightWord) return str;
// We convert things to lowercase for index searching, but pull substrings from
// the submitted text to preserve case. Note: we don't need to htmlEntities the
// string because React will safely encode the text for us.
const lowerStr = str.toLowerCase();
const filterStr = this.props.highlightWord.toLowerCase();
const result = [];
let i = 0;
let ii;
while ((ii = lowerStr.indexOf(filterStr, i)) >= 0) {
// Push any text we missed (first bit/middle of text)
if (ii > i) {
// Push any text we aren't highlighting (middle of text match, or beginning of text)
result.push(<span key={i + 'begin'}>{str.substring(i, ii)}</span>);
}
i = ii; // copy over ii only if we have a match (to preserve i for end-of-text matching)
// Highlight the word the user entered
const substr = str.substring(i, filterStr.length + i);
result.push(<span className='mx_DMInviteDialog_roomTile_highlight' key={i + 'bold'}>{substr}</span>);
i += substr.length;
}
// Push any text we missed (end of text)
if (i < (str.length - 1)) {
result.push(<span key={i + 'end'}>{str.substring(i)}</span>);
}
return result;
}
render() { render() {
const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
let timestamp = null; let timestamp = null;
if (this.props.lastActiveTs) { if (this.props.lastActiveTs) {
@ -56,11 +120,22 @@ class DMRoomTile extends React.PureComponent {
timestamp = <span className='mx_DMInviteDialog_roomTile_time'>{humanTs}</span>; timestamp = <span className='mx_DMInviteDialog_roomTile_time'>{humanTs}</span>;
} }
const avatarSize = 36;
const avatarUrl = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(),
avatarSize, avatarSize, "crop");
return ( return (
<div className='mx_DMInviteDialog_roomTile' onClick={this._onClick}> <div className='mx_DMInviteDialog_roomTile' onClick={this._onClick}>
<MemberAvatar member={this.props.member} width={36} height={36} /> <BaseAvatar
<span className='mx_DMInviteDialog_roomTile_name'>{this.props.member.name}</span> url={avatarUrl}
<span className='mx_DMInviteDialog_roomTile_userId'>{this.props.member.userId}</span> name={this.props.member.name}
idName={this.props.member.userId}
width={avatarSize}
height={avatarSize}
/>
<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} {timestamp}
</div> </div>
); );
@ -73,6 +148,8 @@ export default class DMInviteDialog extends React.PureComponent {
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}; };
_debounceTimer: number = null;
constructor() { constructor() {
super(); super();
@ -83,6 +160,7 @@ export default class DMInviteDialog extends React.PureComponent {
numRecentsShown: INITIAL_ROOMS_SHOWN, numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(), suggestions: this._buildSuggestions(),
numSuggestionsShown: INITIAL_ROOMS_SHOWN, numSuggestionsShown: INITIAL_ROOMS_SHOWN,
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
}; };
} }
@ -163,7 +241,7 @@ export default class DMInviteDialog extends React.PureComponent {
} }
return b.score - a.score; return b.score - a.score;
}); });
return members.map(m => ({userId: m.userId, user: m.member})); return members.map(m => ({userId: m.member.userId, user: m.member}));
} }
_startDm = () => { _startDm = () => {
@ -175,7 +253,35 @@ export default class DMInviteDialog extends React.PureComponent {
}; };
_updateFilter = (e) => { _updateFilter = (e) => {
this.setState({filterText: e.target.value}); const term = e.target.value;
this.setState({filterText: term});
// Debounce server lookups to reduce spam. We don't clear the existing server
// results because they might still be vaguely accurate, likewise for races which
// could happen here.
if (this._debounceTimer) {
clearTimeout(this._debounceTimer);
}
this._debounceTimer = setTimeout(() => {
MatrixClientPeg.get().searchUserDirectory({term}).then(r => {
if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make
// these results useful. This is a race we want to avoid because we could overwrite
// more accurate results.
return;
}
this.setState({
serverResultsMixin: r.results.map(u => ({
userId: u.user_id,
user: new DirectoryMember(u),
})),
});
}).catch(e => {
console.error("Error searching user directory:");
console.error(e);
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
});
}, 150); // 150ms debounce (human reaction time + some)
}; };
_showMoreRecents = () => { _showMoreRecents = () => {
@ -195,14 +301,40 @@ export default class DMInviteDialog extends React.PureComponent {
}; };
_renderSection(kind: "recents"|"suggestions") { _renderSection(kind: "recents"|"suggestions") {
const 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;
const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this); const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this);
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
if (this.state.filterText && this.state.serverResultsMixin && kind === 'suggestions') {
// only pick out the server results that aren't already covered though
const uniqueServerResults = this.state.serverResultsMixin
.filter(u => !sourceMembers.some(m => m.userId === u.userId));
sourceMembers = sourceMembers.concat(uniqueServerResults);
}
// Hide the section if there's nothing to filter by
if (!sourceMembers || sourceMembers.length === 0) return null; if (!sourceMembers || sourceMembers.length === 0) return null;
// Do some simple filtering on the input before going much further. If we get no results, say so.
if (this.state.filterText) {
const filterBy = this.state.filterText.toLowerCase();
sourceMembers = sourceMembers
.filter(m => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy));
if (sourceMembers.length === 0) {
return (
<div className='mx_DMInviteDialog_section'>
<h3>{sectionName}</h3>
<p>{_t("No results")}</p>
</div>
);
}
}
// 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++;
@ -222,7 +354,13 @@ export default class DMInviteDialog extends React.PureComponent {
} }
const tiles = toRender.map(r => ( const tiles = toRender.map(r => (
<DMRoomTile member={r.user} lastActiveTs={lastActive(r)} key={r.userId} onToggle={this._toggleMember} /> <DMRoomTile
member={r.user}
lastActiveTs={lastActive(r)}
key={r.userId}
onToggle={this._toggleMember}
highlightWord={this.state.filterText}
/>
)); ));
return ( return (
<div className='mx_DMInviteDialog_section'> <div className='mx_DMInviteDialog_section'>
@ -246,7 +384,6 @@ export default class DMInviteDialog extends React.PureComponent {
id="inviteTargets" id="inviteTargets"
value={this.state.filterText} value={this.state.filterText}
onChange={this._updateFilter} onChange={this._updateFilter}
placeholder="TODO: Implement filtering/searching (vector-im/riot-web#11199)"
/> />
</div> </div>
); );