matrix-react-sdk
Version:
SDK for matrix.org using React
1,400 lines (1,136 loc) • 198 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = exports.KIND_CALL_TRANSFER = exports.KIND_INVITE = exports.KIND_DM = void 0;
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
var _react = _interopRequireWildcard(require("react"));
var _languageHandler = require("../../../languageHandler");
var sdk = _interopRequireWildcard(require("../../../index"));
var _MatrixClientPeg = require("../../../MatrixClientPeg");
var _Permalinks = require("../../../utils/permalinks/Permalinks");
var _DMRoomMap = _interopRequireDefault(require("../../../utils/DMRoomMap"));
var _roomMember = require("matrix-js-sdk/src/models/room-member");
var _SdkConfig = _interopRequireDefault(require("../../../SdkConfig"));
var Email = _interopRequireWildcard(require("../../../email"));
var _IdentityServerUtils = require("../../../utils/IdentityServerUtils");
var _UrlUtils = require("../../../utils/UrlUtils");
var _dispatcher = _interopRequireDefault(require("../../../dispatcher/dispatcher"));
var _IdentityAuthClient = _interopRequireDefault(require("../../../IdentityAuthClient"));
var _Modal = _interopRequireDefault(require("../../../Modal"));
var _humanize = require("../../../utils/humanize");
var _createRoom = _interopRequireWildcard(require("../../../createRoom"));
var _RoomInvite = require("../../../RoomInvite");
var _Keyboard = require("../../../Keyboard");
var _actions = require("../../../dispatcher/actions");
var _models = require("../../../stores/room-list/models");
var _RoomListStore = _interopRequireDefault(require("../../../stores/room-list/RoomListStore"));
var _CommunityPrototypeStore = require("../../../stores/CommunityPrototypeStore");
var _SettingsStore = _interopRequireDefault(require("../../../settings/SettingsStore"));
var _UIFeature = require("../../../settings/UIFeature");
var _CountlyAnalytics = _interopRequireDefault(require("../../../CountlyAnalytics"));
var _replaceableComponent = require("../../../utils/replaceableComponent");
var _Media = require("../../../customisations/Media");
var _UserAddress = require("../../../UserAddress");
var _dec, _class, _class2, _temp;
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
const KIND_DM = "dm";
exports.KIND_DM = KIND_DM;
const KIND_INVITE = "invite";
exports.KIND_INVITE = KIND_INVITE;
const KIND_CALL_TRANSFER = "call_transfer";
exports.KIND_CALL_TRANSFER = KIND_CALL_TRANSFER;
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
// This is the interface that is expected by various components in this file. It is a bit
// awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses.
//
// XXX: We should use TypeScript interfaces instead of this weird "abstract" class.
class Member {
/**
* The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email).
*/
get name()
/*: string*/
{
throw new Error("Member class not implemented");
}
/**
* The ID of this Member. For users this should be their user ID. For 3PIDs this should
* be the 3PID address (email).
*/
get userId()
/*: string*/
{
throw new Error("Member class not implemented");
}
/**
* Gets the MXC URL of this Member's avatar. For users this should be their profile's
* avatar MXC URL or null if none set. For 3PIDs this should always be null.
*/
getMxcAvatarUrl()
/*: string*/
{
throw new Error("Member class not implemented");
}
}
class DirectoryMember extends Member {
constructor(userDirResult
/*: {user_id: string, display_name: string, avatar_url: string}*/
) {
super();
(0, _defineProperty2.default)(this, "_userId", void 0);
(0, _defineProperty2.default)(this, "_displayName", void 0);
(0, _defineProperty2.default)(this, "_avatarUrl", void 0);
this._userId = userDirResult.user_id;
this._displayName = userDirResult.display_name;
this._avatarUrl = userDirResult.avatar_url;
} // These next class members are for the Member interface
get name()
/*: string*/
{
return this._displayName || this._userId;
}
get userId()
/*: string*/
{
return this._userId;
}
getMxcAvatarUrl()
/*: string*/
{
return this._avatarUrl;
}
}
class ThreepidMember extends Member {
constructor(id
/*: string*/
) {
super();
(0, _defineProperty2.default)(this, "_id", void 0);
this._id = id;
} // This is a getter that would be falsey on all other implementations. Until we have
// better type support in the react-sdk we can use this trick to determine the kind
// of 3PID we're dealing with, if any.
get isEmail()
/*: boolean*/
{
return this._id.includes('@');
} // These next class members are for the Member interface
get name()
/*: string*/
{
return this._id;
}
get userId()
/*: string*/
{
return this._id;
}
getMxcAvatarUrl()
/*: string*/
{
return null;
}
}
class DMUserTile extends _react.default.PureComponent
/*:: <IDMUserTileProps>*/
{
constructor(...args) {
super(...args);
(0, _defineProperty2.default)(this, "_onRemove", e => {
// Stop the browser from highlighting text
e.preventDefault();
e.stopPropagation();
this.props.onRemove(this.props.member);
});
}
render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const avatarSize = 20;
const avatar = this.props.member.isEmail ? /*#__PURE__*/_react.default.createElement("img", {
className: "mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar",
src: require("../../../../res/img/icon-email-pill-avatar.svg"),
width: avatarSize,
height: avatarSize
}) : /*#__PURE__*/_react.default.createElement(BaseAvatar, {
className: "mx_InviteDialog_userTile_avatar",
url: this.props.member.getMxcAvatarUrl() ? (0, _Media.mediaFromMxc)(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize) : null,
name: this.props.member.name,
idName: this.props.member.userId,
width: avatarSize,
height: avatarSize
});
let closeButton;
if (this.props.onRemove) {
closeButton = /*#__PURE__*/_react.default.createElement(AccessibleButton, {
className: "mx_InviteDialog_userTile_remove",
onClick: this._onRemove
}, /*#__PURE__*/_react.default.createElement("img", {
src: require("../../../../res/img/icon-pill-remove.svg"),
alt: (0, _languageHandler._t)('Remove'),
width: 8,
height: 8
}));
}
return /*#__PURE__*/_react.default.createElement("span", {
className: "mx_InviteDialog_userTile"
}, /*#__PURE__*/_react.default.createElement("span", {
className: "mx_InviteDialog_userTile_pill"
}, avatar, /*#__PURE__*/_react.default.createElement("span", {
className: "mx_InviteDialog_userTile_name"
}, this.props.member.name)), closeButton);
}
}
class DMRoomTile extends _react.default.PureComponent
/*:: <IDMRoomTileProps>*/
{
constructor(...args) {
super(...args);
(0, _defineProperty2.default)(this, "_onClick", e => {
// Stop the browser from highlighting text
e.preventDefault();
e.stopPropagation();
this.props.onToggle(this.props.member);
});
}
_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( /*#__PURE__*/_react.default.createElement("span", {
key: i + 'begin'
}, 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( /*#__PURE__*/_react.default.createElement("span", {
className: "mx_InviteDialog_roomTile_highlight",
key: i + 'bold'
}, substr));
i += substr.length;
} // Push any text we missed (end of text)
if (i < str.length) {
result.push( /*#__PURE__*/_react.default.createElement("span", {
key: i + 'end'
}, str.substring(i)));
}
return result;
}
render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
let timestamp = null;
if (this.props.lastActiveTs) {
const humanTs = (0, _humanize.humanizeTime)(this.props.lastActiveTs);
timestamp = /*#__PURE__*/_react.default.createElement("span", {
className: "mx_InviteDialog_roomTile_time"
}, humanTs);
}
const avatarSize = 36;
const avatar = this.props.member.isEmail ? /*#__PURE__*/_react.default.createElement("img", {
src: require("../../../../res/img/icon-email-pill-avatar.svg"),
width: avatarSize,
height: avatarSize
}) : /*#__PURE__*/_react.default.createElement(BaseAvatar, {
url: this.props.member.getMxcAvatarUrl() ? (0, _Media.mediaFromMxc)(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize) : null,
name: this.props.member.name,
idName: this.props.member.userId,
width: avatarSize,
height: avatarSize
});
let checkmark = null;
if (this.props.isSelected) {
// To reduce flickering we put the 'selected' room tile above the real avatar
checkmark = /*#__PURE__*/_react.default.createElement("div", {
className: "mx_InviteDialog_roomTile_selected"
});
} // To reduce flickering we put the checkmark on top of the actual avatar (prevents
// the browser from reloading the image source when the avatar remounts).
const stackedAvatar = /*#__PURE__*/_react.default.createElement("span", {
className: "mx_InviteDialog_roomTile_avatarStack"
}, avatar, checkmark);
const caption = this.props.member.isEmail ? (0, _languageHandler._t)("Invite by email") : this._highlightName(this.props.member.userId);
return /*#__PURE__*/_react.default.createElement("div", {
className: "mx_InviteDialog_roomTile",
onClick: this._onClick
}, stackedAvatar, /*#__PURE__*/_react.default.createElement("span", {
className: "mx_InviteDialog_roomTile_nameStack"
}, /*#__PURE__*/_react.default.createElement("div", {
className: "mx_InviteDialog_roomTile_name"
}, this._highlightName(this.props.member.name)), /*#__PURE__*/_react.default.createElement("div", {
className: "mx_InviteDialog_roomTile_userId"
}, caption)), timestamp);
}
}
let InviteDialog = (_dec = (0, _replaceableComponent.replaceableComponent)("views.dialogs.InviteDialog"), _dec(_class = (_temp = _class2 = class InviteDialog extends _react.default.PureComponent
/*:: <IInviteDialogProps, IInviteDialogState>*/
{
// actually number because we're in the browser
constructor(props) {
super(props);
(0, _defineProperty2.default)(this, "_debounceTimer", null);
(0, _defineProperty2.default)(this, "_editorRef", null);
(0, _defineProperty2.default)(this, "onConsultFirstChange", ev => {
this.setState({
consultFirst: ev.target.checked
});
});
(0, _defineProperty2.default)(this, "_startDm", async () => {
this.setState({
busy: true
});
const client = _MatrixClientPeg.MatrixClientPeg.get();
const targets = this._convertFilter();
const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible.
let existingRoom
/*: Room*/
;
if (targetIds.length === 1) {
existingRoom = (0, _createRoom.findDMForUser)(client, targetIds[0]);
} else {
existingRoom = _DMRoomMap.default.shared().getDMRoomForIdentifiers(targetIds);
}
if (existingRoom) {
_dispatcher.default.dispatch({
action: 'view_room',
room_id: existingRoom.roomId,
should_peek: false,
joining: false
});
this.props.onFinished();
return;
}
const createRoomOptions = {
inlineErrors: true
}; // XXX: Type out `createRoomOptions`
if ((0, _createRoom.privateShouldBeEncrypted)()) {
// Check whether all users have uploaded device keys before.
// If so, enable encryption in the new room.
const has3PidMembers = targets.some(t => t instanceof ThreepidMember);
if (!has3PidMembers) {
const allHaveDeviceKeys = await (0, _createRoom.canEncryptToAllUsers)(client, targetIds);
if (allHaveDeviceKeys) {
createRoomOptions.encryption = true;
}
}
} // Check if it's a traditional DM and create the room if required.
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
try {
const isSelf = targetIds.length === 1 && targetIds[0] === client.getUserId();
if (targetIds.length === 1 && !isSelf) {
createRoomOptions.dmUserId = targetIds[0];
}
if (targetIds.length > 1) {
createRoomOptions.createOpts = targetIds.reduce((roomOptions, address) => {
const type = (0, _UserAddress.getAddressType)(address);
if (type === 'email') {
const invite
/*: IInvite3PID*/
= {
id_server: client.getIdentityServerUrl(true),
medium: 'email',
address
};
roomOptions.invite_3pid.push(invite);
} else if (type === 'mx-user-id') {
roomOptions.invite.push(address);
}
return roomOptions;
}, {
invite: [],
invite_3pid: []
});
}
await (0, _createRoom.default)(createRoomOptions);
this.props.onFinished();
} catch (err) {
console.error(err);
this.setState({
busy: false,
errorText: (0, _languageHandler._t)("We couldn't create your DM.")
});
}
});
(0, _defineProperty2.default)(this, "_inviteUsers", async () => {
const startTime = _CountlyAnalytics.default.getTimestamp();
this.setState({
busy: true
});
this._convertFilter();
const targets = this._convertFilter();
const targetIds = targets.map(t => t.userId);
const cli = _MatrixClientPeg.MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
if (!room) {
console.error("Failed to find the room to invite users to");
this.setState({
busy: false,
errorText: (0, _languageHandler._t)("Something went wrong trying to invite the users.")
});
return;
}
try {
const result = await (0, _RoomInvite.inviteMultipleToRoom)(this.props.roomId, targetIds);
_CountlyAnalytics.default.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this._shouldAbortAfterInviteError(result)) {
// handles setting error message too
this.props.onFinished();
}
if (cli.isRoomEncrypted(this.props.roomId)) {
const visibilityEvent = room.currentState.getStateEvents("m.room.history_visibility", "");
const visibility = visibilityEvent && visibilityEvent.getContent() && visibilityEvent.getContent().history_visibility;
if (visibility == "world_readable" || visibility == "shared") {
const invitedUsers = [];
for (const [addr, state] of Object.entries(result.states)) {
if (state === "invited" && (0, _UserAddress.getAddressType)(addr) === "mx-user-id") {
invitedUsers.push(addr);
}
}
console.log("Sharing history with", invitedUsers);
cli.sendSharedHistoryKeys(this.props.roomId, invitedUsers);
}
}
} catch (err) {
console.error(err);
this.setState({
busy: false,
errorText: (0, _languageHandler._t)("We couldn't invite those users. Please check the users you want to invite and try again.")
});
}
});
(0, _defineProperty2.default)(this, "_transferCall", async () => {
this._convertFilter();
const targets = this._convertFilter();
const targetIds = targets.map(t => t.userId);
if (targetIds.length > 1) {
this.setState({
errorText: (0, _languageHandler._t)("A call can only be transferred to a single user.")
});
}
if (this.state.consultFirst) {
const dmRoomId = await (0, _createRoom.ensureDMExists)(_MatrixClientPeg.MatrixClientPeg.get(), targetIds[0]);
_dispatcher.default.dispatch({
action: 'place_call',
type: this.props.call.type,
room_id: dmRoomId,
transferee: this.props.call
});
_dispatcher.default.dispatch({
action: 'view_room',
room_id: dmRoomId,
should_peek: false,
joining: false
});
this.props.onFinished();
} else {
this.setState({
busy: true
});
try {
await this.props.call.transfer(targetIds[0]);
this.setState({
busy: false
});
this.props.onFinished();
} catch (e) {
this.setState({
busy: false,
errorText: (0, _languageHandler._t)("Failed to transfer call")
});
}
}
});
(0, _defineProperty2.default)(this, "_onKeyDown", e => {
if (this.state.busy) return;
const value = e.target.value.trim();
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
if (!value && this.state.targets.length > 0 && e.key === _Keyboard.Key.BACKSPACE && !hasModifiers) {
// when the field is empty and the user hits backspace remove the right-most target
e.preventDefault();
this._removeMember(this.state.targets[this.state.targets.length - 1]);
} else if (value && e.key === _Keyboard.Key.ENTER && !hasModifiers) {
// when the user hits enter with something in their field try to convert it
e.preventDefault();
this._convertFilter();
} else if (value && e.key === _Keyboard.Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) {
// when the user hits space and their input looks like an e-mail/MXID then try to convert it
e.preventDefault();
this._convertFilter();
}
});
(0, _defineProperty2.default)(this, "_updateSuggestions", async term => {
_MatrixClientPeg.MatrixClientPeg.get().searchUserDirectory({
term
}).then(async 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;
}
if (!r.results) r.results = []; // While we're here, try and autocomplete a search result for the mxid itself
// if there's no matches (and the input looks like a mxid).
if (term[0] === '@' && term.indexOf(':') > 1) {
try {
const profile = await _MatrixClientPeg.MatrixClientPeg.get().getProfileInfo(term);
if (profile) {
// If we have a profile, we have enough information to assume that
// the mxid can be invited - add it to the list. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: profile['displayname'],
avatar_url: profile['avatar_url']
});
}
} catch (e) {
console.warn("Non-fatal error trying to make an invite for a user ID");
console.warn(e); // Add a result anyways, just without a profile. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: term,
avatar_url: null
});
}
}
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
}); // Whenever we search the directory, also try to search the identity server. It's
// all debounced the same anyways.
if (!this.state.canUseIdentityServer) {
// The user doesn't have an identity server set - warn them of that.
this.setState({
tryingIdentityServer: true
});
return;
}
if (term.indexOf('@') > 0 && Email.looksValid(term) && _SettingsStore.default.getValue(_UIFeature.UIFeature.IdentityServer)) {
// Start off by suggesting the plain email while we try and resolve it
// to a real account.
this.setState({
// per above: the userId is a lie here - it's just a regular identifier
threepidResultsMixin: [{
user: new ThreepidMember(term),
userId: term
}]
});
try {
const authClient = new _IdentityAuthClient.default();
const token = await authClient.getAccessToken();
if (term !== this.state.filterText) return; // abandon hope
const lookup = await _MatrixClientPeg.MatrixClientPeg.get().lookupThreePid('email', term, undefined, // callback
token);
if (term !== this.state.filterText) return; // abandon hope
if (!lookup || !lookup.mxid) {
// We weren't able to find anyone - we're already suggesting the plain email
// as an alternative, so do nothing.
return;
} // We append the user suggestion to give the user an option to click
// the email anyways, and so we don't cause things to jump around. In
// theory, the user would see the user pop up and think "ah yes, that
// person!"
const profile = await _MatrixClientPeg.MatrixClientPeg.get().getProfileInfo(lookup.mxid);
if (term !== this.state.filterText || !profile) return; // abandon hope
this.setState({
threepidResultsMixin: [...this.state.threepidResultsMixin, {
user: new DirectoryMember({
user_id: lookup.mxid,
display_name: profile.displayname,
avatar_url: profile.avatar_url
}),
userId: lookup.mxid
}]
});
} catch (e) {
console.error("Error searching identity server:");
console.error(e);
this.setState({
threepidResultsMixin: []
}); // clear results because it's moderately fatal
}
}
});
(0, _defineProperty2.default)(this, "_updateFilter", e => {
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(() => {
this._updateSuggestions(term);
}, 150); // 150ms debounce (human reaction time + some)
});
(0, _defineProperty2.default)(this, "_showMoreRecents", () => {
this.setState({
numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN
});
});
(0, _defineProperty2.default)(this, "_showMoreSuggestions", () => {
this.setState({
numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN
});
});
(0, _defineProperty2.default)(this, "_toggleMember", (member
/*: Member*/
) => {
if (!this.state.busy) {
let filterText = this.state.filterText;
const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member);
if (idx >= 0) {
targets.splice(idx, 1);
} else {
targets.push(member);
filterText = ""; // clear the filter when the user accepts a suggestion
}
this.setState({
targets,
filterText
});
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
}
}
});
(0, _defineProperty2.default)(this, "_removeMember", (member
/*: Member*/
) => {
const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member);
if (idx >= 0) {
targets.splice(idx, 1);
this.setState({
targets
});
}
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
}
});
(0, _defineProperty2.default)(this, "_onPaste", async e => {
if (this.state.filterText) {
// if the user has already typed something, just let them
// paste normally.
return;
} // Prevent the text being pasted into the input
e.preventDefault(); // Process it as a list of addresses to add instead
const text = e.clipboardData.getData("text");
const possibleMembers = [// If we can avoid hitting the profile endpoint, we should.
...this.state.recents, ...this.state.suggestions, ...this.state.serverResultsMixin, ...this.state.threepidResultsMixin];
const toAdd = [];
const failed = [];
const potentialAddresses = text.split(/[\s,]+/).map(p => p.trim()).filter(p => !!p); // filter empty strings
for (const address of potentialAddresses) {
const member = possibleMembers.find(m => m.userId === address);
if (member) {
toAdd.push(member.user);
continue;
}
if (address.indexOf('@') > 0 && Email.looksValid(address)) {
toAdd.push(new ThreepidMember(address));
continue;
}
if (address[0] !== '@') {
failed.push(address); // not a user ID
continue;
}
try {
const profile = await _MatrixClientPeg.MatrixClientPeg.get().getProfileInfo(address);
const displayName = profile ? profile.displayname : null;
const avatarUrl = profile ? profile.avatar_url : null;
toAdd.push(new DirectoryMember({
user_id: address,
display_name: displayName,
avatar_url: avatarUrl
}));
} catch (e) {
console.error("Error looking up profile for " + address);
console.error(e);
failed.push(address);
}
}
if (failed.length > 0) {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
_Modal.default.createTrackedDialog('Invite Paste Fail', '', QuestionDialog, {
title: (0, _languageHandler._t)('Failed to find the following users'),
description: (0, _languageHandler._t)("The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", {
csvNames: failed.join(", ")
}),
button: (0, _languageHandler._t)('OK')
});
}
this.setState({
targets: [...this.state.targets, ...toAdd]
});
});
(0, _defineProperty2.default)(this, "_onClickInputArea", e => {
// Stop the browser from highlighting text
e.preventDefault();
e.stopPropagation();
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
}
});
(0, _defineProperty2.default)(this, "_onUseDefaultIdentityServerClick", e => {
e.preventDefault(); // Update the IS in account data. Actually using it may trigger terms.
// eslint-disable-next-line react-hooks/rules-of-hooks
(0, _IdentityServerUtils.useDefaultIdentityServer)();
this.setState({
canUseIdentityServer: true,
tryingIdentityServer: false
});
});
(0, _defineProperty2.default)(this, "_onManageSettingsClick", e => {
e.preventDefault();
_dispatcher.default.fire(_actions.Action.ViewUserSettings);
this.props.onFinished();
});
(0, _defineProperty2.default)(this, "_onCommunityInviteClick", e => {
this.props.onFinished();
(0, _RoomInvite.showCommunityInviteDialog)(_CommunityPrototypeStore.CommunityPrototypeStore.instance.getSelectedCommunityId());
});
if (props.kind === KIND_INVITE && !props.roomId) {
throw new Error("When using KIND_INVITE a roomId is required for an InviteDialog");
} else if (props.kind === KIND_CALL_TRANSFER && !props.call) {
throw new Error("When using KIND_CALL_TRANSFER a call is required for an InviteDialog");
}
const alreadyInvited = new Set([_MatrixClientPeg.MatrixClientPeg.get().getUserId(), _SdkConfig.default.get()['welcomeUserId']]);
if (props.roomId) {
const room = _MatrixClientPeg.MatrixClientPeg.get().getRoom(props.roomId);
if (!room) throw new Error("Room ID given to InviteDialog does not look like a room");
room.getMembersWithMembership('invite').forEach(m => alreadyInvited.add(m.userId));
room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId)); // add banned users, so we don't try to invite them
room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId));
_CountlyAnalytics.default.instance.trackBeginInvite(props.roomId);
}
this.state = {
targets: [],
// array of Member objects (see interface above)
filterText: this.props.initialText,
recents: InviteDialog.buildRecents(alreadyInvited),
numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(alreadyInvited),
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
serverResultsMixin: [],
threepidResultsMixin: [],
canUseIdentityServer: !!_MatrixClientPeg.MatrixClientPeg.get().getIdentityServerUrl(),
tryingIdentityServer: false,
consultFirst: false,
// These two flags are used for the 'Go' button to communicate what is going on.
busy: false,
errorText: null
};
this._editorRef = /*#__PURE__*/(0, _react.createRef)();
}
componentDidMount() {
if (this.props.initialText) {
this._updateSuggestions(this.props.initialText);
}
}
static buildRecents(excludedTargetIds
/*: Set<string>*/
)
/*: {userId: string, user: RoomMember, lastActive: number}[]*/
{
const rooms = _DMRoomMap.default.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
const dmTaggedRooms = _RoomListStore.default.instance.orderedLists[_models.DefaultTagID.DM] || [];
const myUserId = _MatrixClientPeg.MatrixClientPeg.get().getUserId();
for (const dmRoom of dmTaggedRooms) {
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);
for (const member of otherMembers) {
if (rooms[member.userId]) continue; // already have a room
console.warn(`Adding DM room for ${member.userId} as ${dmRoom.roomId} from tag, not DM map`);
rooms[member.userId] = dmRoom;
}
}
const recents = [];
for (const userId in rooms) {
// Filter out user IDs that are already in the room / should be excluded
if (excludedTargetIds.has(userId)) {
console.warn(`[Invite:Recents] Excluding ${userId} from recents`);
continue;
}
const room = rooms[userId];
const member = room.getMember(userId);
if (!member) {
// just skip people who don't have memberships for some reason
console.warn(`[Invite:Recents] ${userId} is missing a member object in their own DM (${room.roomId})`);
continue;
} // Find the last timestamp for a message event
const searchTypes = ["m.room.message", "m.room.encrypted", "m.sticker"];
const maxSearchEvents = 20; // to prevent traversing history
let lastEventTs = 0;
if (room.timeline && room.timeline.length) {
for (let i = room.timeline.length - 1; i >= 0; i--) {
const ev = room.timeline[i];
if (searchTypes.includes(ev.getType())) {
lastEventTs = ev.getTs();
break;
}
if (room.timeline.length - i > maxSearchEvents) break;
}
}
if (!lastEventTs) {
// something weird is going on with this room
console.warn(`[Invite:Recents] ${userId} (${room.roomId}) has a weird last timestamp: ${lastEventTs}`);
continue;
}
recents.push({
userId,
user: member,
lastActive: lastEventTs
});
}
if (!recents) console.warn("[Invite:Recents] No recents to suggest!"); // Sort the recents by last active to save us time later
recents.sort((a, b) => b.lastActive - a.lastActive);
return recents;
}
_buildSuggestions(excludedTargetIds
/*: Set<string>*/
)
/*: {userId: string, user: RoomMember}[]*/
{
const maxConsideredMembers = 200;
const joinedRooms = _MatrixClientPeg.MatrixClientPeg.get().getRooms().filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers); // Generates { userId: {member, rooms[]} }
const memberRooms = joinedRooms.reduce((members, room) => {
// Filter out DMs (we'll handle these in the recents section)
if (_DMRoomMap.default.shared().getUserIdForRoomId(room.roomId)) {
return members; // Do nothing
}
const joinedMembers = room.getJoinedMembers().filter(u => !excludedTargetIds.has(u.userId));
for (const member of joinedMembers) {
// Filter out user IDs that are already in the room / should be excluded
if (excludedTargetIds.has(member.userId)) {
continue;
}
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;
}, {}); // Generates { userId: {member, numRooms, score} }
const memberScores = Object.values(memberRooms).reduce((scores, entry
/*: {member: RoomMember, rooms: Room[]}*/
) => {
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;
}, {}); // Now that we have scores for being in rooms, boost those people who have sent messages
// recently, as a way to improve the quality of suggestions. We do this by checking every
// room to see who has sent a message in the last few hours, and giving them a score
// which correlates to the freshness of their message. In theory, this results in suggestions
// which are closer to "continue this conversation" rather than "this person exists".
const trueJoinedRooms = _MatrixClientPeg.MatrixClientPeg.get().getRooms().filter(r => r.getMyMembership() === 'join');
const now = new Date().getTime();
const earliestAgeConsidered = now - 60 * 60 * 1000; // 1 hour ago
const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
const lastSpoke = {}; // userId: timestamp
const lastSpokeMembers = {}; // userId: room member
for (const room of trueJoinedRooms) {
// Skip low priority rooms and DMs
const isDm = _DMRoomMap.default.shared().getUserIdForRoomId(room.roomId);
if (Object.keys(room.tags).includes("m.lowpriority") || isDm) {
continue;
}
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i >= Math.max(0, events.length - maxMessagesConsidered); i--) {
const ev = events[i];
if (excludedTargetIds.has(ev.getSender())) {
continue;
}
if (ev.getTs() <= earliestAgeConsidered) {
break; // give up: all events from here on out are too old
}
if (!lastSpoke[ev.getSender()] || lastSpoke[ev.getSender()] < ev.getTs()) {
lastSpoke[ev.getSender()] = ev.getTs();
lastSpokeMembers[ev.getSender()] = room.getMember(ev.getSender());
}
}
}
for (const userId in lastSpoke) {
const ts = lastSpoke[userId];
const member = lastSpokeMembers[userId];
if (!member) continue; // skip people we somehow don't have profiles for
// Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
// boost we'll try and award at least +1.0 for making the list, with +4.0 being
// an approximate maximum for being selected.
const distanceFromNow = Math.abs(now - ts); // abs to account for slight future messages
const inverseTime = now - earliestAgeConsidered - distanceFromNow;
const scoreBoost = Math.max(1, inverseTime / (15 * 60 * 1000)); // 15min segments to keep scores sane
let record = memberScores[userId];
if (!record) record = memberScores[userId] = {
score: 0
};
record.member = member;
record.score += scoreBoost;
}
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.member.userId,
user: m.member
}));
}
_shouldAbortAfterInviteError(result)
/*: boolean*/
{
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error');
if (failedUsers.length > 0) {
console.log("Failed to invite users: ", result);
this.setState({
busy: false,
errorText: (0, _languageHandler._t)("Failed to invite the following users to chat: %(csvUsers)s", {
csvUsers: failedUsers.join(", ")
})
});
return true; // abort
}
return false;
}
_convertFilter()
/*: Member[]*/
{
// Check to see if there's anything to convert first
if (!this.state.filterText || !this.state.filterText.includes('@')) return this.state.targets || [];
let newMember
/*: Member*/
;
if (this.state.filterText.startsWith('@')) {
// Assume mxid
newMember = new DirectoryMember({
user_id: this.state.filterText,
display_name: null,
avatar_url: null
});
} else if (_SettingsStore.default.getValue(_UIFeature.UIFeature.IdentityServer)) {
// Assume email
newMember = new ThreepidMember(this.state.filterText);
}
const newTargets = [...(this.state.targets || []), newMember];
this.setState({
targets: newTargets,
filterText: ''
});
return newTargets;
}
_renderSection(kind
/*: "recents"|"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;
let sectionName = kind === 'recents' ? (0, _languageHandler._t)("Recent Conversations") : (0, _languageHandler._t)("Suggestions");
let sectionSubname = null;
if (kind === 'suggestions' && _CommunityPrototypeStore.CommunityPrototypeStore.instance.getSelectedCommunityId()) {
const communityName = _CommunityPrototypeStore.CommunityPrototypeStore.instance.getSelectedCommunityName();
sectionSubname = (0, _languageHandler._t)("May include members not in %(communityName)s", {
communityName
});
}
if (this.props.kind === KIND_INVITE) {
sectionName = kind === 'recents' ? (0, _languageHandler._t)("Recently Direct Messaged") : (0, _languageHandler._t)("Suggestions");
} // Mix in the server results if we have any, but only if we're searching. We track the additional
// members separately because we want to filter sourceMembers but trust the mixin arrays to have
// the right members in them.
let priorityAdditionalMembers = []; // Shows up before our own suggestions, higher quality
let otherAdditionalMembers = []; // Shows up after our own suggestions, lower quality
const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin;
if (this.state.filterText && hasMixins && kind === 'suggestions') {
// We don't want to duplicate members though, so just exclude anyone we've already seen.
// The type of u is a pain to define but members of both mixins have the 'userId' property
const notAlreadyExists = (u
/*: any*/
) =>
/*: boolean*/
{
return !sourceMembers.some(m => m.userId === u.userId) && !priorityAdditionalMembers.some(m => m.userId === u.userId) && !otherAdditionalMembers.some(m => m.userId === u.userId);
};
otherAdditionalMembers = this.state.serverResultsMixin.filter(notAlreadyExists);
priorityAdditionalMembers = this.state.threepidResultsMixin.filter(notAlreadyExists);
}
const hasAdditionalMembers = priorityAdditionalMembers.length > 0 || otherAdditionalMembers.length > 0; // Hide the section if there's nothing to filter by
if (sourceMembers.length === 0 && !hasAdditionalMembers) 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 && !hasAdditionalMembers) {
return /*#__PURE__*/_react.default.createElement("div", {
className: "mx_InviteDialog_section"
}, /*#__PURE__*/_react.default.createElement("h3", null, sectionName), /*#__PURE__*/_react.default.createElement("p", null, (0, _languageHandler._t)("No results")));
}
} // Now we mix in the additional members. Again, we presume these have already been filtered. We
// also assume they are more relevant than our suggestions and prepend them to the list.
sourceMembers = [...priorityAdditionalMembers, ...sourceMembers, ...otherAdditionalMembers]; // 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 = sourceMembers.slice(0, showNum);
const hasMore = toRender.length < sourceMembers.length;
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
let showMore = null;
if (hasMore) {
showMore = /*#__PURE__*/_react.default.createElement(AccessibleButton, {
onClick: showMoreFn,
kind: "link"
}, (0, _languageHandler._t)("Show more"));
}
const tiles = toRender.map(r => /*#__PURE__*/_react.default.createElement(DMRoomTile, {
member: r.user,
lastActiveTs: lastActive(r),
key: r.userId,
onToggle: this._toggleMember,
highlightWord: this.state.filterText,
isSelected: this.state.targets.some(t => t.userId === r.userId)
}));
return /*#__PURE__*/_react.default.createElement("div", {
className: "mx_InviteDialog_section"
}, /*#__PURE__*/_react.default.createElement("h3", null, sectionName), sectionSubname ? /*#__PURE__*/_react.default.createElement("p", {
className: "mx_InviteDialog_subname"
}, sectionSubname) : null, tiles, showMore);
}
_renderEditor() {
const targets = this.state.targets.map(t => /*#__PURE__*/_react.default.createElement(DMUserTile, {
member: t,
onRemove: !this.state.busy && this._removeMember,
key: t.userId
}));
const input = /*#__PURE__*/_react.default.createElement("input", {
type: "text",
onKeyDown: this._onKeyDown,
onChange: this._updateFilter,
value: this.state.filterText,
ref: this._editorRef,
onPaste: this._onPaste,
autoFocus: true,
disabled: this.state.busy,
autoComplete: "off"
});
return /*#__PURE__*/_react.default.createElement("div", {
className: "mx_InviteDialog_editor",
onClick: this._onClickInputArea
}, targets, input);
}
_renderIdentityServerWarning() {
if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer || !_SettingsStore.default.getValue(_UIFeature.UIFeature.IdentityServer)) {
return null;
}
const defaultIdentityServerUrl = (0, _IdentityServerUtils.getDefaultIdentityServerUrl)();
if (defaultIdentityServerUrl) {
return /*#__PURE__*/_react.default.createElement("div", {
className: "mx_AddressPickerDialog_identityServer"
}, (0, _languageHandler._t)("Use an identity server to invite by email. " + "<default>Use the default (%(defaultIdentityServerName)s)</default> " + "or manage in <settings>Settings</settings>.", {
defaultIdentityServerName: (0, _UrlUtils.abbreviateUrl)(defaultIdentityServerUrl)
}, {
default: sub => /*#__PURE__*/_react.default.createElement("a", {
href: "#",
onClick: this._onUseDefaultIdentityServerClick
}, sub),
settings: sub => /*#__PURE__*/_react.default.createElement("a", {
href: "#",
onClick: this._onManageSettingsClick
}, sub)
}));
} else {
return /*#__PURE__*/_react.default.createElement("div", {
className: "mx_AddressPickerDialog_identityServer"
}, (0, _languageHandler._t)("Use an identity server to invite by email. " + "Manage in <settings>Settings</settings>.", {}, {
settings: sub => /*#__PURE__*/_react.default.createElement("a", {
href: "#",
onClick: this._onManageSettingsClick
}, sub)
}));
}
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const Spinner = sdk.getComponent("elements.Spinner");
let spinner = null;
if (this.state.busy) {
spinner = /*#__PURE__*/_react.default.createElement(Spinner, {
w: 20,
h: 20
});
}
let title;
let helpText;
let buttonText;
let goButtonFn;
let consultSection;
let keySharingWarning = /*#__PURE__*/_react.default.createElement("span", null);
const identityServersEnabled = _SettingsStore.default.getValue(_UIFeature.UIFeature.IdentityServer);
const cli = _MatrixClientPeg.MatrixClientPeg.get();
const userId = cli.getUserId();
if (this.props.kind === KIND_DM) {
title = (0, _languageHandler._t)("Direct Messages");
if (identityServersEnabled) {
helpText = (0, _languageHandler._t)("Start a conversation with someone using their name, email address or username (like <userId/>).", {}, {
userId: () => {
return /*#__PURE__*/_react.default.createElement("a", {
href: (0, _Permalinks.makeUserPermalink)(userId),
rel: "noreferrer noopener",
target: "_blank"
}, userId);
}
});
} else {
hel