diff --git a/res/css/views/dialogs/_DMInviteDialog.scss b/res/css/views/dialogs/_DMInviteDialog.scss index 1153ecb0d4..364c796f16 100644 --- a/res/css/views/dialogs/_DMInviteDialog.scss +++ b/res/css/views/dialogs/_DMInviteDialog.scss @@ -77,5 +77,9 @@ limitations under the License. float: right; line-height: 36px; // Height of the avatar to keep the time vertically aligned } + + .mx_DMInviteDialog_roomTile_highlight { + font-weight: 900; + } } diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index 2854c914f8..c5e9c92131 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -24,17 +24,45 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import {RoomMember} from "matrix-js-sdk/lib/matrix"; import * as humanize from "humanize"; import SdkConfig from "../../../SdkConfig"; +import {getHttpUriForMxc} from "matrix-js-sdk/lib/content-repo"; // TODO: [TravisR] Make this generic for all kinds of invites 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 { + _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 { static propTypes = { + // Has properties to match RoomMember: userId (str), name (str), getMxcAvatarUrl(): string member: PropTypes.object.isRequired, lastActiveTs: PropTypes.number, onToggle: PropTypes.func.isRequired, + highlightWord: PropTypes.string, }; _onClick = (e) => { @@ -45,8 +73,44 @@ class DMRoomTile extends React.PureComponent { 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({str.substring(i, ii)}); + } + + 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({substr}); + i += substr.length; + } + + // Push any text we missed (end of text) + if (i < (str.length - 1)) { + result.push({str.substring(i)}); + } + + return result; + } + render() { - const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); + const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); let timestamp = null; if (this.props.lastActiveTs) { @@ -56,11 +120,22 @@ class DMRoomTile extends React.PureComponent { timestamp = {humanTs}; } + const avatarSize = 36; + const avatarUrl = getHttpUriForMxc( + MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(), + avatarSize, avatarSize, "crop"); + return (
- - {this.props.member.name} - {this.props.member.userId} + + {this._highlightName(this.props.member.name)} + {this._highlightName(this.props.member.userId)} {timestamp}
); @@ -73,6 +148,8 @@ export default class DMInviteDialog extends React.PureComponent { onFinished: PropTypes.func.isRequired, }; + _debounceTimer: number = null; + constructor() { super(); @@ -83,6 +160,7 @@ export default class DMInviteDialog extends React.PureComponent { numRecentsShown: INITIAL_ROOMS_SHOWN, suggestions: this._buildSuggestions(), 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 members.map(m => ({userId: m.userId, user: m.member})); + return members.map(m => ({userId: m.member.userId, user: m.member})); } _startDm = () => { @@ -175,7 +253,35 @@ export default class DMInviteDialog extends React.PureComponent { }; _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 = () => { @@ -195,14 +301,40 @@ export default class DMInviteDialog extends React.PureComponent { }; _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; const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this); const lastActive = (m) => kind === 'recents' ? m.lastActive : null; 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; + // 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 ( +
+

{sectionName}

+

{_t("No results")}

+
+ ); + } + } + // 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. if (showNum === sourceMembers.length - 1) showNum++; @@ -222,7 +354,13 @@ export default class DMInviteDialog extends React.PureComponent { } const tiles = toRender.map(r => ( - + )); return (
@@ -246,7 +384,6 @@ export default class DMInviteDialog extends React.PureComponent { id="inviteTargets" value={this.state.filterText} onChange={this._updateFilter} - placeholder="TODO: Implement filtering/searching (vector-im/riot-web#11199)" />
);