sliding sync: add lazy-loading member support (#9530)

* sliding sync: add lazy-loading member support

Also swap to `$ME` constants when referring to our own member event.

* Hook into existing LL logic when showing the MemberList

* Linting

* Use consts in js sdk not react sdk

* Add jest tests

* linting

* Store the room in the test

* Fix up getRoom impl

* Add MemberListStore

* Use the right context in MemberList tests

* Fix RightPanel-test

* Always return members even if we lazy load

* Add MemberListStore tests

* Additional tests
This commit is contained in:
kegsay 2022-11-18 19:05:00 +00:00 committed by GitHub
parent d626f71fdd
commit acdcda78f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 658 additions and 210 deletions

View file

@ -29,14 +29,12 @@ import { ClientEvent } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher/dispatcher';
import { isValid3pidInvite } from "../../../RoomInvite";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import BaseCard from "../right_panel/BaseCard";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import SettingsStore from "../../../settings/SettingsStore";
import TruncatedList from '../elements/TruncatedList';
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
@ -47,15 +45,12 @@ import BaseAvatar from '../avatars/BaseAvatar';
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import PosthogTrackers from "../../../PosthogTrackers";
import { SDKContext } from '../../../contexts/SDKContext';
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
// 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;
interface IProps {
roomId: string;
searchQuery: string;
@ -65,7 +60,6 @@ interface IProps {
interface IState {
loading: boolean;
members: Array<RoomMember>;
filteredJoinedMembers: Array<RoomMember>;
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
canInvite: boolean;
@ -76,35 +70,16 @@ interface IState {
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
constructor(props) {
static contextType = SDKContext;
public context!: React.ContextType<typeof SDKContext>;
constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props);
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
// show an empty list
this.state = this.getMembersState([]);
} else {
this.state = this.getMembersState(this.roomMembers());
}
cli.on(ClientEvent.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;
}
public componentDidMount() {
const cli = MatrixClientPeg.get();
this.state = this.getMembersState([], []);
this.showPresence = context.memberListStore.isPresenceEnabled();
this.mounted = true;
if (cli.hasLazyLoadMembersEnabled()) {
this.showMembersAccordingToMembershipWithLL();
cli.on(RoomEvent.MyMembership, this.onMyMembership);
} else {
this.listenForMembersChanges();
}
this.listenForMembersChanges();
}
private listenForMembersChanges(): void {
@ -118,6 +93,12 @@ export default class MemberList extends React.Component<IProps, IState> {
cli.on(UserEvent.LastPresenceTs, this.onUserPresenceChange);
cli.on(UserEvent.Presence, this.onUserPresenceChange);
cli.on(UserEvent.CurrentlyActive, this.onUserPresenceChange);
cli.on(ClientEvent.Room, this.onRoom); // invites & joining after peek
cli.on(RoomEvent.MyMembership, this.onMyMembership);
}
componentDidMount(): void {
this.updateListNow(true);
}
public componentWillUnmount() {
@ -138,33 +119,6 @@ export default class MemberList extends React.Component<IProps, IState> {
this.updateList.cancel();
}
/**
* 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
*/
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") {
this.setState({ loading: true });
try {
await room.loadMembersIfNeeded();
} 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()));
}
}
}
private get canInvite(): boolean {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
@ -175,14 +129,11 @@ export default class MemberList extends React.Component<IProps, IState> {
);
}
private getMembersState(members: Array<RoomMember>): IState {
// set the state after determining showPresence to make sure it's
// taken into account while rendering
private getMembersState(invitedMembers: Array<RoomMember>, joinedMembers: Array<RoomMember>): IState {
return {
loading: false,
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join', this.props.searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', this.props.searchQuery),
filteredJoinedMembers: joinedMembers,
filteredInvitedMembers: invitedMembers,
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
@ -209,12 +160,13 @@ export default class MemberList extends React.Component<IProps, IState> {
// 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();
this.updateListNow(true);
};
private onMyMembership = (room: Room, membership: string, oldMembership: string): void => {
if (room.roomId === this.props.roomId && membership === "join") {
this.showMembersAccordingToMembershipWithLL();
if (room.roomId === this.props.roomId && membership === "join" && oldMembership !== "join") {
// we just joined the room, load the member list
this.updateListNow(true);
}
};
@ -239,61 +191,29 @@ export default class MemberList extends React.Component<IProps, IState> {
};
private updateList = throttle(() => {
this.updateListNow();
this.updateListNow(false);
}, 500, { leading: true, trailing: true });
private updateListNow(): void {
const members = this.roomMembers();
private async updateListNow(showLoadingSpinner: boolean): Promise<void> {
if (!this.mounted) {
return;
}
if (showLoadingSpinner) {
this.setState({ loading: true });
}
const { joined, invited } = await this.context.memberListStore.loadMemberList(
this.props.roomId, this.props.searchQuery,
);
if (!this.mounted) {
return;
}
this.setState({
loading: false,
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join', this.props.searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', this.props.searchQuery),
filteredJoinedMembers: joined,
filteredInvitedMembers: invited,
});
}
private getMembersWithUser(): Array<RoomMember> {
if (!this.props.roomId) return [];
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
if (!room) return [];
const allMembers = Object.values(room.currentState.members);
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.slice(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;
}
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;
}
private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
};
@ -357,59 +277,14 @@ export default class MemberList extends React.Component<IProps, IState> {
}
}
// 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.
const userA = memberA.user;
const userB = memberB.user;
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);
if (idxA !== idxB) {
return idxA - idxB;
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>, snapshot?: any): void {
if (prevProps.searchQuery !== this.props.searchQuery) {
this.updateListNow(false);
}
// Second by power level
if (memberA.powerLevel !== memberB.powerLevel) {
return memberB.powerLevel - memberA.powerLevel;
}
// Third by last active
if (this.showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
return userB.getLastActiveTs() - userA.getLastActiveTs();
}
// Fourth by name (alphabetical)
return this.collator.compare(this.sortNames.get(memberA), this.sortNames.get(memberB));
};
}
private onSearchQueryChanged = (searchQuery: string): void => {
this.props.onSearchQueryChanged(searchQuery);
this.setState({
filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(this.state.members, 'invite', searchQuery),
});
};
private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
@ -419,22 +294,6 @@ export default class MemberList extends React.Component<IProps, IState> {
});
};
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;
});
}
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