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:
commit
b44e4ab45c
2 changed files with 150 additions and 9 deletions
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue