element-web/src/components/views/rooms/MemberList.tsx

596 lines
23 KiB
TypeScript
Raw Normal View History

/*
2016-01-07 07:06:39 +03:00
Copyright 2015, 2016 OpenMarket Ltd
2017-07-25 17:56:16 +03:00
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher/dispatcher';
import { isValid3pidInvite } from "../../../RoomInvite";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
2020-09-08 12:19:51 +03:00
import BaseCard from "../right_panel/BaseCard";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import { User } from "matrix-js-sdk/src/models/user";
import TruncatedList from '../elements/TruncatedList';
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
import AccessibleButton from '../elements/AccessibleButton';
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar';
2021-07-01 20:35:38 +03:00
import { throttle } from 'lodash';
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
2019-12-27 20:04:14 +03:00
// Regex applied to filter our punctuation in member names before applying sort, to fuzzy it a little
// matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g;
2019-12-26 22:27:04 +03:00
interface IProps {
roomId: string;
onClose(): void;
}
interface IState {
loading: boolean;
members: Array<RoomMember>;
filteredJoinedMembers: Array<RoomMember>;
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
canInvite: boolean;
truncateAtJoined: number;
truncateAtInvited: number;
searchQuery: string;
}
@replaceableComponent("views.rooms.MemberList")
export default class MemberList extends React.Component<IProps, IState> {
private showPresence = true;
private mounted = false;
private collator: Intl.Collator;
private sortNames = new Map<RoomMember, string>(); // RoomMember -> sortName
2020-08-29 14:14:16 +03:00
constructor(props) {
super(props);
2018-09-03 12:13:56 +03:00
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
// show an empty list
this.state = this.getMembersState([]);
2018-09-03 12:13:56 +03:00
} else {
this.state = this.getMembersState(this.roomMembers());
2018-09-03 12:13:56 +03:00
}
cli.on("Room", this.onRoom); // invites & joining after peek
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
const hsUrl = MatrixClientPeg.get().baseUrl;
this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true;
2020-08-29 14:14:16 +03:00
}
2020-09-03 12:34:19 +03:00
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
2020-08-29 14:51:37 +03:00
const cli = MatrixClientPeg.get();
this.mounted = true;
2020-08-29 14:51:37 +03:00
if (cli.hasLazyLoadMembersEnabled()) {
this.showMembersAccordingToMembershipWithLL();
2020-08-29 14:51:37 +03:00
cli.on("Room.myMembership", this.onMyMembership);
} else {
this.listenForMembersChanges();
2020-08-29 14:51:37 +03:00
}
}
private listenForMembersChanges(): void {
const cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
cli.on("RoomMember.name", this.onRoomMemberName);
cli.on("RoomState.events", this.onRoomStateEvent);
// We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on("User.lastPresenceTs", this.onUserPresenceChange);
cli.on("User.presence", this.onUserPresenceChange);
cli.on("User.currentlyActive", this.onUserPresenceChange);
// cli.on("Room.timeline", this.onRoomTimeline);
2020-08-29 14:14:16 +03:00
}
2020-08-29 14:14:16 +03:00
componentWillUnmount() {
this.mounted = false;
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.members", this.onRoomStateMember);
cli.removeListener("RoomMember.name", this.onRoomMemberName);
cli.removeListener("Room.myMembership", this.onMyMembership);
cli.removeListener("RoomState.events", this.onRoomStateEvent);
cli.removeListener("Room", this.onRoom);
cli.removeListener("User.lastPresenceTs", this.onUserPresenceChange);
cli.removeListener("User.presence", this.onUserPresenceChange);
cli.removeListener("User.currentlyActive", this.onUserPresenceChange);
}
// cancel any pending calls to the rate_limited_funcs
2021-07-01 20:35:38 +03:00
this.updateList.cancel();
2020-08-29 14:14:16 +03:00
}
2018-09-17 20:20:26 +03:00
/**
* If lazy loading is enabled, either:
* show a spinner and load the members if the user is joined,
* or show the members available so far if the user is invited
2018-09-17 20:20:26 +03:00
*/
private async showMembersAccordingToMembershipWithLL(): Promise<void> {
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
const membership = room && room.getMyMembership();
if (membership === "join") {
2021-06-29 15:11:58 +03:00
this.setState({ loading: true });
try {
await room.loadMembersIfNeeded();
2018-09-17 21:01:55 +03:00
} catch (ex) {/* already logged in RoomView */}
if (this.mounted) {
this.setState(this.getMembersState(this.roomMembers()));
this.listenForMembersChanges();
}
} else {
// show the members we already have loaded
this.setState(this.getMembersState(this.roomMembers()));
}
}
2020-08-29 14:14:16 +03:00
}
private get canInvite(): boolean {
2021-05-19 15:16:10 +03:00
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
return room && room.canInvite(cli.getUserId());
}
private getMembersState(members: Array<RoomMember>): IState {
// set the state after determining showPresence to make sure it's
// taken into account while rendering
2018-09-03 12:13:56 +03:00
return {
loading: false,
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join'),
filteredInvitedMembers: this.filterMembers(members, 'invite'),
2021-05-19 15:16:10 +03:00
canInvite: this.canInvite,
2018-09-03 12:13:56 +03:00
// ideally we'd size this to the page height, but
// in practice I find that a little constraining
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
searchQuery: "",
};
2020-08-29 14:14:16 +03:00
}
2018-09-03 12:13:56 +03:00
private onUserPresenceChange = (event: MatrixEvent, user: User): void => {
2015-09-18 20:39:16 +03:00
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// ever attaching their own listener.
const tile = this.refs[user.userId];
// console.log(`Got presence update for ${user.userId}. hasTile=${!!tile}`);
if (tile) {
this.updateList(); // reorder the membership list
2015-09-18 20:39:16 +03:00
}
2020-08-29 14:14:16 +03:00
};
private onRoom = (room: Room): void => {
2015-09-18 20:39:16 +03:00
if (room.roomId !== this.props.roomId) {
return;
}
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
this.showMembersAccordingToMembershipWithLL();
2020-08-29 14:14:16 +03:00
};
private onMyMembership = (room: Room, membership: string, oldMembership: string): void => {
if (room.roomId === this.props.roomId && membership === "join") {
this.showMembersAccordingToMembershipWithLL();
}
2020-08-29 14:14:16 +03:00
};
2015-09-18 20:39:16 +03:00
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this.updateList();
2020-08-29 14:14:16 +03:00
};
2015-09-18 20:39:16 +03:00
private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this.updateList();
2020-08-29 14:14:16 +03:00
};
private onRoomStateEvent = (event: MatrixEvent, state: RoomState): void => {
if (event.getRoomId() === this.props.roomId &&
event.getType() === "m.room.third_party_invite") {
this.updateList();
}
2021-05-19 12:01:04 +03:00
2021-05-19 15:16:10 +03:00
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
2020-08-29 14:14:16 +03:00
};
2021-07-01 20:35:38 +03:00
private updateList = throttle(() => {
this.updateListNow();
2021-07-01 20:35:38 +03:00
}, 500, { leading: true, trailing: true });
private updateListNow(): void {
2021-06-29 15:11:58 +03:00
const members = this.roomMembers();
this.setState({
loading: false,
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join', this.state.searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', this.state.searchQuery),
});
2020-08-29 14:14:16 +03:00
}
2015-06-24 15:48:39 +03:00
private getMembersWithUser(): Array<RoomMember> {
if (!this.props.roomId) return [];
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
2018-09-10 18:32:40 +03:00
if (!room) return [];
const allMembers = Object.values(room.currentState.members);
2015-09-18 20:39:16 +03:00
allMembers.forEach((member) => {
// work around a race where you might have a room member object
// before the user object exists. This may or may not cause
// https://github.com/vector-im/vector-web/issues/186
if (!member.user) {
member.user = cli.getUser(member.userId);
}
this.sortNames.set(
member,
(member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, ""),
);
// XXX: this user may have no lastPresenceTs value!
// the right solution here is to fix the race rather than leave it as 0
});
return allMembers;
2020-08-29 14:14:16 +03:00
}
2015-09-18 20:39:16 +03:00
private roomMembers(): Array<RoomMember> {
const allMembers = this.getMembersWithUser();
const filteredAndSortedMembers = allMembers.filter((m) => {
return (
m.membership === 'join' || m.membership === 'invite'
);
});
const language = SettingsStore.getValue("language");
this.collator = new Intl.Collator(language, { sensitivity: 'base', ignorePunctuation: false });
filteredAndSortedMembers.sort(this.memberSort);
return filteredAndSortedMembers;
2020-08-29 14:14:16 +03:00
}
2015-11-30 18:13:28 +03:00
private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
};
private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
};
private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element=> {
// For now we'll pretend this is any entity. It should probably be a separate tile.
2017-07-25 17:56:16 +03:00
const text = _t("and %(count)s others...", { count: overflowCount });
return (
2016-01-21 20:39:12 +03:00
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={onClick} />
);
};
2015-11-30 18:13:28 +03:00
private showMoreJoinedMemberList = (): void => {
this.setState({
truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
});
2020-08-29 14:14:16 +03:00
};
private showMoreInvitedMemberList = (): void => {
this.setState({
truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
});
2020-08-29 14:14:16 +03:00
};
2015-11-30 18:13:28 +03:00
/**
* SHOULD ONLY BE USED BY TESTS
*/
public memberString(member: RoomMember): string {
if (!member) {
return "(null)";
} else {
const u = member.user;
return (
"(" +
member.name +
", " +
member.powerLevel +
", " +
(u ? u.lastActiveAgo : "<null>") +
", " +
(u ? u.getLastActiveTs() : "<null>") +
", " +
(u ? u.currentlyActive : "<null>") +
", " +
(u ? u.presence : "<null>") +
")"
);
}
2020-08-29 14:14:16 +03:00
}
2015-11-30 18:13:28 +03:00
2016-04-18 16:48:55 +03:00
// returns negative if a comes before b,
// returns 0 if a and b are equivalent in ordering
// returns positive if a comes after b.
private memberSort = (memberA: RoomMember, memberB: RoomMember): number => {
// order by presence, with "active now" first.
// ...and then by power level
// ...and then by last active
// ...and then alphabetically.
// We could tiebreak instead by "last recently spoken in this room" if we wanted to.
// console.log(`Comparing userA=${this.memberString(memberA)} userB=${this.memberString(memberB)}`);
const userA = memberA.user;
const userB = memberB.user;
// if (!userA) console.log("!! MISSING USER FOR A-SIDE: " + memberA.name + " !!");
// if (!userB) console.log("!! MISSING USER FOR B-SIDE: " + memberB.name + " !!");
if (!userA && !userB) return 0;
if (userA && !userB) return -1;
if (!userA && userB) return 1;
// First by presence
if (this.showPresence) {
const convertPresence = (p) => p === 'unavailable' ? 'online' : p;
const presenceIndex = p => {
const order = ['active', 'online', 'offline'];
const idx = order.indexOf(convertPresence(p));
return idx === -1 ? order.length : idx; // unknown states at the end
};
const idxA = presenceIndex(userA.currentlyActive ? 'active' : userA.presence);
const idxB = presenceIndex(userB.currentlyActive ? 'active' : userB.presence);
// console.log(`userA_presenceGroup=${idxA} userB_presenceGroup=${idxB}`);
if (idxA !== idxB) {
// console.log("Comparing on presence group - returning");
return idxA - idxB;
}
}
// Second by power level
if (memberA.powerLevel !== memberB.powerLevel) {
// console.log("Comparing on power level - returning");
return memberB.powerLevel - memberA.powerLevel;
}
2015-11-30 18:13:28 +03:00
// Third by last active
if (this.showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
// console.log("Comparing on last active timestamp - returning");
return userB.getLastActiveTs() - userA.getLastActiveTs();
}
// Fourth by name (alphabetical)
return this.collator.compare(this.sortNames.get(memberA), this.sortNames.get(memberB));
2020-08-29 14:14:16 +03:00
};
2015-11-30 18:13:28 +03:00
private onSearchQueryChanged = (searchQuery: string): void => {
this.setState({
searchQuery,
filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(this.state.members, 'invite', searchQuery),
});
2020-08-29 14:14:16 +03:00
};
private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
dis.dispatch({
action: 'view_3pid_invite',
event: inviteEvent,
});
2020-08-29 14:14:16 +03:00
};
private filterMembers(members: Array<RoomMember>, membership: string, query?: string): Array<RoomMember> {
return members.filter((m) => {
if (query) {
query = query.toLowerCase();
const matchesName = m.name.toLowerCase().indexOf(query) !== -1;
const matchesId = m.userId.toLowerCase().indexOf(query) !== -1;
if (!matchesName && !matchesId) {
return false;
}
}
return m.membership === membership;
});
2020-08-29 14:14:16 +03:00
}
private getPending3PidInvites(): Array<MatrixEvent> {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (room) {
return room.currentState.getStateEvents("m.room.third_party_invite").filter(function(e) {
if (!isValid3pidInvite(e)) return false;
// discard all invites which have a m.room.member event since we've
// already added them.
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey());
if (memberEvent) return false;
return true;
});
}
2020-08-29 14:14:16 +03:00
}
private makeMemberTiles(members: Array<RoomMember | MatrixEvent>) {
return members.map((m) => {
if (m instanceof RoomMember) {
// Is a Matrix invite
return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this.showPresence} />;
} else {
// Is a 3pid invite
return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true}
onClick={() => this.onPending3pidInviteClick(m)} />;
}
2015-11-30 18:13:28 +03:00
});
2020-08-29 14:14:16 +03:00
}
2015-11-30 18:13:28 +03:00
private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
2021-06-29 15:11:58 +03:00
return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end));
};
private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;
private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this.getPending3PidInvites());
}
return this.makeMemberTiles(targets.slice(start, end));
2020-08-29 14:51:37 +03:00
};
private getChildCountInvited = (): number => {
return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
2021-06-29 15:11:58 +03:00
};
2020-08-29 14:14:16 +03:00
render() {
if (this.state.loading) {
2020-09-08 12:19:51 +03:00
return <BaseCard
className="mx_MemberList"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<Spinner />
</BaseCard>;
}
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
let inviteButton;
if (room && room.getMyMembership() === 'join') {
let inviteButtonText = _t("Invite to this room");
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
} else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space");
}
inviteButton = (
<AccessibleButton
className="mx_MemberList_invite"
onClick={this.onInviteButtonClick}
disabled={!this.state.canInvite}
>
<span>{ inviteButtonText }</span>
</AccessibleButton>
);
}
let invitedHeader;
let invitedSection;
if (this.getChildCountInvited() > 0) {
invitedHeader = <h2>{ _t("Invited") }</h2>;
invitedSection = (
<TruncatedList
className="mx_MemberList_section mx_MemberList_invited"
truncateAt={this.state.truncateAtInvited}
createOverflowElement={this.createOverflowTileInvited}
getChildren={this.getChildrenInvited}
getChildCount={this.getChildCountInvited}
/>
);
2015-11-30 18:13:28 +03:00
}
2020-09-08 12:19:51 +03:00
const footer = (
<SearchBox
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={ _t('Filter room members') }
onSearch={ this.onSearchQueryChanged } />
2015-11-30 18:13:28 +03:00
);
2020-09-08 12:19:51 +03:00
let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader;
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />
</div>;
}
2020-09-08 12:19:51 +03:00
return <BaseCard
className="mx_MemberList"
header={<React.Fragment>
{ scopeHeader }
{ inviteButton }
</React.Fragment>}
2020-09-08 12:19:51 +03:00
footer={footer}
onClose={this.props.onClose}
previousPhase={previousPhase}
2020-09-08 12:19:51 +03:00
>
<div className="mx_MemberList_wrapper">
<TruncatedList
className="mx_MemberList_section mx_MemberList_joined"
truncateAt={this.state.truncateAtJoined}
createOverflowElement={this.createOverflowTileJoined}
getChildren={this.getChildrenJoined}
getChildCount={this.getChildCountJoined} />
2020-09-08 12:19:51 +03:00
{ invitedHeader }
{ invitedSection }
</div>
</BaseCard>;
2020-08-29 14:14:16 +03:00
}
onInviteButtonClick = (): void => {
if (MatrixClientPeg.get().isGuest()) {
2021-06-29 15:11:58 +03:00
dis.dispatch({ action: 'require_registration' });
return;
}
// call AddressPickerDialog
dis.dispatch({
action: 'view_invite',
roomId: this.props.roomId,
});
2020-08-29 14:14:16 +03:00
};
}