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)"
/>
);