From df25a988104a978b23cc3aa45ae739c97cfe31ef Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 3 Jan 2020 20:35:30 -0700 Subject: [PATCH 1/3] Implement basic filtering for invite targets Part of https://github.com/vector-im/riot-web/issues/11200 --- res/css/views/dialogs/_DMInviteDialog.scss | 4 + src/HtmlUtils.js | 5 ++ .../views/dialogs/DMInviteDialog.js | 75 +++++++++++++++++-- 3 files changed, 78 insertions(+), 6 deletions(-) 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/HtmlUtils.js b/src/HtmlUtils.js index 7cdff26a21..ce677e6c68 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -528,3 +528,8 @@ export function checkBlockNode(node) { return false; } } + +export function htmlEntitiesEncode(str: string) { + // Source: https://stackoverflow.com/a/18750001/7037379 + return str.replace(/[\u00A0-\u9999<>&]/gim, i => `&#${i.charCodeAt(0)};`); +} diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index bdeae6bc3e..ba3221d632 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -24,6 +24,7 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import {RoomMember} from "matrix-js-sdk/lib/matrix"; import * as humanize from "humanize"; import SdkConfig from "../../../SdkConfig"; +import {htmlEntitiesEncode} from "../../../HtmlUtils"; // TODO: [TravisR] Make this generic for all kinds of invites @@ -35,6 +36,7 @@ class DMRoomTile extends React.PureComponent { member: PropTypes.object.isRequired, lastActiveTs: PropTypes.number, onToggle: PropTypes.func.isRequired, + highlightWord: PropTypes.string, }; _onClick = (e) => { @@ -45,6 +47,44 @@ class DMRoomTile extends React.PureComponent { this.props.onToggle(this.props.member.userId); }; + _highlightName(str: string) { + if (!this.props.highlightWord) return str; + + // First encode the thing to avoid injection + str = htmlEntitiesEncode(str); + + // We convert things to lowercase for index searching, but pull substrings from + // the submitted text to preserve case. + 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) + 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"); @@ -59,8 +99,8 @@ class DMRoomTile extends React.PureComponent { return (
- {this.props.member.name} - {this.props.member.userId} + {this._highlightName(this.props.member.name)} + {this._highlightName(this.props.member.userId)} {timestamp}
); @@ -158,7 +198,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 = () => { @@ -190,14 +230,32 @@ 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"); + // 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++; @@ -217,7 +275,13 @@ export default class DMInviteDialog extends React.PureComponent { } const tiles = toRender.map(r => ( - + )); return (
@@ -241,7 +305,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)" />
); From 8b4c1e3dec79f699951d87fb11eb29541637c943 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 3 Jan 2020 21:17:48 -0700 Subject: [PATCH 2/3] Support searching in the user directory for invite targets Part of https://github.com/vector-im/riot-web/issues/11200 --- .../views/dialogs/DMInviteDialog.js | 84 ++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index ba3221d632..aec64919a0 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -25,14 +25,41 @@ import {RoomMember} from "matrix-js-sdk/lib/matrix"; import * as humanize from "humanize"; import SdkConfig from "../../../SdkConfig"; import {htmlEntitiesEncode} from "../../../HtmlUtils"; +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, @@ -86,7 +113,7 @@ class DMRoomTile extends React.PureComponent { } render() { - const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); + const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); let timestamp = null; if (this.props.lastActiveTs) { @@ -96,9 +123,20 @@ 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._highlightName(this.props.member.name)} {this._highlightName(this.props.member.userId)} {timestamp} @@ -113,6 +151,8 @@ export default class DMInviteDialog extends React.PureComponent { onFinished: PropTypes.func.isRequired, }; + _debounceTimer: number = null; + constructor() { super(); @@ -123,6 +163,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 }; } @@ -210,7 +251,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 = () => { @@ -236,6 +305,15 @@ export default class DMInviteDialog extends React.PureComponent { 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; From bef824e84ee36769712fc3c48171032056875df0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 6 Jan 2020 12:21:59 -0700 Subject: [PATCH 3/3] Remove harmful html entities encoding and other style nits React will take care of this for us. It's harmful because simple characters get converted to something illegible. --- src/HtmlUtils.js | 5 ----- src/components/views/dialogs/DMInviteDialog.js | 12 ++++-------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index ce677e6c68..7cdff26a21 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -528,8 +528,3 @@ export function checkBlockNode(node) { return false; } } - -export function htmlEntitiesEncode(str: string) { - // Source: https://stackoverflow.com/a/18750001/7037379 - return str.replace(/[\u00A0-\u9999<>&]/gim, i => `&#${i.charCodeAt(0)};`); -} diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index aec64919a0..bb3e38a304 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -24,7 +24,6 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import {RoomMember} from "matrix-js-sdk/lib/matrix"; import * as humanize from "humanize"; import SdkConfig from "../../../SdkConfig"; -import {htmlEntitiesEncode} from "../../../HtmlUtils"; import {getHttpUriForMxc} from "matrix-js-sdk/lib/content-repo"; // TODO: [TravisR] Make this generic for all kinds of invites @@ -77,11 +76,9 @@ class DMRoomTile extends React.PureComponent { _highlightName(str: string) { if (!this.props.highlightWord) return str; - // First encode the thing to avoid injection - str = htmlEntitiesEncode(str); - // We convert things to lowercase for index searching, but pull substrings from - // the submitted text to preserve case. + // 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(); @@ -92,8 +89,8 @@ class DMRoomTile extends React.PureComponent { 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) - result.push({str.substring(i, ii)}); + // 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) @@ -333,7 +330,6 @@ export default class DMInviteDialog extends React.PureComponent { } } - // 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++;