From 31d5617c97e34deda775a05c1e0337f2c7bd23d8 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 3 Jan 2020 19:41:06 -0700 Subject: [PATCH] Add suggestions for which users to invite to chat Fixes https://github.com/vector-im/riot-web/issues/11198 Note this doesn't implement the entire algorithm in 11198 because it feels too complicated at this stage. Instead, the idea is to review the suggestions closer to when the whole dialog is complete and fix them then: https://github.com/vector-im/riot-web/issues/11769 Algorithm for picking members is largely based on https://github.com/matrix-org/matrix-react-sdk/commit/db5218e19a26b650d4af37de81f516c68ab7e9b8 --- .../views/dialogs/DMInviteDialog.js | 91 ++++++++++++++++--- src/i18n/strings/en_EN.json | 3 +- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index ff498e3e75..bdeae6bc3e 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -23,6 +23,7 @@ import {makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import DMRoomMap from "../../../utils/DMRoomMap"; import {RoomMember} from "matrix-js-sdk/lib/matrix"; import * as humanize from "humanize"; +import SdkConfig from "../../../SdkConfig"; // TODO: [TravisR] Make this generic for all kinds of invites @@ -36,10 +37,6 @@ class DMRoomTile extends React.PureComponent { onToggle: PropTypes.func.isRequired, }; - constructor() { - super(); - } - _onClick = (e) => { // Stop the browser from highlighting text e.preventDefault(); @@ -84,6 +81,8 @@ export default class DMInviteDialog extends React.PureComponent { filterText: "", recents: this._buildRecents(), numRecentsShown: INITIAL_ROOMS_SHOWN, + suggestions: this._buildSuggestions(), + numSuggestionsShown: INITIAL_ROOMS_SHOWN, }; } @@ -109,6 +108,59 @@ export default class DMInviteDialog extends React.PureComponent { return recents; } + _buildSuggestions(): {userId: string, user: RoomMember} { + const maxConsideredMembers = 200; + const client = MatrixClientPeg.get(); + const excludedUserIds = [client.getUserId(), SdkConfig.get()['welcomeUserId']]; + const joinedRooms = client.getRooms() + .filter(r => r.getMyMembership() === 'join') + .filter(r => r.getJoinedMemberCount() <= maxConsideredMembers); + const memberRooms = joinedRooms.reduce((members, room) => { + const joinedMembers = room.getJoinedMembers().filter(u => !excludedUserIds.includes(u.userId)); + for (const member of joinedMembers) { + if (!members[member.userId]) { + members[member.userId] = { + member: member, + // Track the room size of the 'picked' member so we can use the profile of + // the smallest room (likely a DM). + pickedMemberRoomSize: room.getJoinedMemberCount(), + rooms: [], + }; + } + + members[member.userId].rooms.push(room); + + if (room.getJoinedMemberCount() < members[member.userId].pickedMemberRoomSize) { + members[member.userId].member = member; + members[member.userId].pickedMemberRoomSize = room.getJoinedMemberCount(); + } + } + return members; + }, {/* userId => {member, rooms[]} */}); + const memberScores = Object.values(memberRooms).reduce((scores, entry) => { + const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0); + const maxRange = maxConsideredMembers * entry.rooms.length; + scores[entry.member.userId] = { + member: entry.member, + numRooms: entry.rooms.length, + score: Math.max(0, Math.pow(1 - (numMembersTotal / maxRange), 5)), + }; + return scores; + }, {/* userId => {member, numRooms, score} */}); + const members = Object.values(memberScores); + members.sort((a, b) => { + if (a.score === b.score) { + if (a.numRooms === b.numRooms) { + return a.member.userId.localeCompare(b.member.userId); + } + + return b.numRooms - a.numRooms; + } + return b.score - a.score; + }); + return members.map(m => ({userId: m.userId, user: m.member})); + } + _startDm = () => { this.props.onFinished(this.state.targets); }; @@ -125,6 +177,10 @@ export default class DMInviteDialog extends React.PureComponent { this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN}); }; + _showMoreSuggestions = () => { + this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN}); + }; + _toggleMember = (userId) => { const targets = this.state.targets.map(t => t); // cheap clone for mutation const idx = targets.indexOf(userId); @@ -133,29 +189,39 @@ export default class DMInviteDialog extends React.PureComponent { this.setState({targets}); }; - _renderRecents() { - if (!this.state.recents || this.state.recents.length === 0) return null; + _renderSection(kind: "recents"|"suggestions") { + const 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"); + + if (!sourceMembers || sourceMembers.length === 0) return null; + + // 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++; // .slice() will return an incomplete array but won't error on us if we go too far - const toRender = this.state.recents.slice(0, this.state.numRecentsShown); - const hasMore = toRender.length < this.state.recents.length; + const toRender = sourceMembers.slice(0, showNum); + const hasMore = toRender.length < sourceMembers.length; const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); let showMore = null; if (hasMore) { showMore = ( - + {_t("Show more")} ); } const tiles = toRender.map(r => ( - + )); return (
-

{_t("Recent Conversations")}

+

{sectionName}

{tiles} {showMore}
@@ -209,7 +275,8 @@ export default class DMInviteDialog extends React.PureComponent { {_t("Go")}
- {this._renderRecents()} + {this._renderSection('recents')} + {this._renderSection('suggestions')} ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cac3f2f619..4666a1fe9d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1431,8 +1431,9 @@ "View Servers in Room": "View Servers in Room", "Toolbox": "Toolbox", "Developer Tools": "Developer Tools", - "Show more": "Show more", "Recent Conversations": "Recent Conversations", + "Suggestions": "Suggestions", + "Show more": "Show more", "Direct Messages": "Direct Messages", "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.": "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.", "Go": "Go",