From df25a988104a978b23cc3aa45ae739c97cfe31ef Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 3 Jan 2020 20:35:30 -0700 Subject: [PATCH] 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)" />
);