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

@ -49,6 +49,9 @@ import { EventType } from 'matrix-js-sdk/src/@types/event';
import {
MSC3575Filter,
MSC3575List,
MSC3575_STATE_KEY_LAZY,
MSC3575_STATE_KEY_ME,
MSC3575_WILDCARD,
SlidingSync,
} from 'matrix-js-sdk/src/sliding-sync';
import { logger } from "matrix-js-sdk/src/logger";
@ -60,19 +63,35 @@ const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000;
// the things to fetch when a user clicks on a room
const DEFAULT_ROOM_SUBSCRIPTION_INFO = {
timeline_limit: 50,
required_state: [
["*", "*"], // all events
],
// missing required_state which will change depending on the kind of room
include_old_rooms: {
timeline_limit: 0,
required_state: [ // state needed to handle space navigation and tombstone chains
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""],
[EventType.SpaceChild, "*"],
[EventType.SpaceParent, "*"],
[EventType.SpaceChild, MSC3575_WILDCARD],
[EventType.SpaceParent, MSC3575_WILDCARD],
[EventType.RoomMember, MSC3575_STATE_KEY_ME],
],
},
};
// lazy load room members so rooms like Matrix HQ don't take forever to load
const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted";
const UNENCRYPTED_SUBSCRIPTION = Object.assign({
required_state: [
[MSC3575_WILDCARD, MSC3575_WILDCARD], // all events
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // except for m.room.members, get our own membership
[EventType.RoomMember, MSC3575_STATE_KEY_LAZY], // ...and lazy load the rest.
],
}, DEFAULT_ROOM_SUBSCRIPTION_INFO);
// we need all the room members in encrypted rooms because we need to know which users to encrypt
// messages for.
const ENCRYPTED_SUBSCRIPTION = Object.assign({
required_state: [
[MSC3575_WILDCARD, MSC3575_WILDCARD], // all events
],
}, DEFAULT_ROOM_SUBSCRIPTION_INFO);
export type PartialSlidingSyncRequest = {
filters?: MSC3575Filter;
@ -109,12 +128,12 @@ export class SlidingSyncManager {
public configure(client: MatrixClient, proxyUrl: string): SlidingSync {
this.client = client;
this.listIdToIndex = {};
DEFAULT_ROOM_SUBSCRIPTION_INFO.include_old_rooms.required_state.push(
[EventType.RoomMember, client.getUserId()],
);
// by default use the encrypted subscription as that gets everything, which is a safer
// default than potentially missing member events.
this.slidingSync = new SlidingSync(
proxyUrl, [], DEFAULT_ROOM_SUBSCRIPTION_INFO, client, SLIDING_SYNC_TIMEOUT_MS,
proxyUrl, [], ENCRYPTED_SUBSCRIPTION, client, SLIDING_SYNC_TIMEOUT_MS,
);
this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION);
// set the space list
this.slidingSync.setList(this.getOrAllocateListIndex(SlidingSyncManager.ListSpaces), {
ranges: [[0, 20]],
@ -129,18 +148,18 @@ export class SlidingSyncManager {
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.SpaceChild, "*"], // all space children
[EventType.SpaceParent, "*"], // all space parents
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
include_old_rooms: {
timeline_limit: 0,
required_state: [
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.SpaceChild, "*"], // all space children
[EventType.SpaceParent, "*"], // all space parents
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
},
filters: {
@ -207,16 +226,16 @@ export class SlidingSyncManager {
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.RoomMember, this.client.getUserId()], // lets the client calculate that we are in fact in the room
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
include_old_rooms: {
timeline_limit: 0,
required_state: [
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.SpaceChild, "*"], // all space children
[EventType.SpaceParent, "*"], // all space parents
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
},
};
@ -252,9 +271,21 @@ export class SlidingSyncManager {
} else {
subscriptions.delete(roomId);
}
logger.log("SlidingSync setRoomVisible:", roomId, visible);
const room = this.client.getRoom(roomId);
let shouldLazyLoad = !this.client.isRoomEncrypted(roomId);
if (!room) {
// default to safety: request all state if we can't work it out. This can happen if you
// refresh the app whilst viewing a room: we call setRoomVisible before we know anything
// about the room.
shouldLazyLoad = false;
}
logger.log("SlidingSync setRoomVisible:", roomId, visible, "shouldLazyLoad:", shouldLazyLoad);
if (shouldLazyLoad) {
// lazy load this room
this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_NAME);
}
const p = this.slidingSync.modifyRoomSubscriptions(subscriptions);
if (this.client.getRoom(roomId)) {
if (room) {
return roomId; // we have data already for this room, show immediately e.g it's in a list
}
try {
@ -297,7 +328,7 @@ export class SlidingSyncManager {
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
// we don't include_old_rooms here in an effort to reduce the impact of spidering all rooms
// on the user's account. This means some data in the search dialog results may be inaccurate

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

View file

@ -21,6 +21,7 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import LegacyCallHandler from "../LegacyCallHandler";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { SlidingSyncManager } from "../SlidingSyncManager";
import { MemberListStore } from "../stores/MemberListStore";
import { RoomNotificationStateStore } from "../stores/notifications/RoomNotificationStateStore";
import RightPanelStore from "../stores/right-panel/RightPanelStore";
import { RoomViewStore } from "../stores/RoomViewStore";
@ -54,6 +55,7 @@ export class SdkContextClass {
// All protected fields to make it easier to derive test stores
protected _WidgetPermissionStore?: WidgetPermissionStore;
protected _MemberListStore?: MemberListStore;
protected _RightPanelStore?: RightPanelStore;
protected _RoomNotificationStateStore?: RoomNotificationStateStore;
protected _RoomViewStore?: RoomViewStore;
@ -125,6 +127,12 @@ export class SdkContextClass {
}
return this._PosthogAnalytics;
}
public get memberListStore(): MemberListStore {
if (!this._MemberListStore) {
this._MemberListStore = new MemberListStore(this);
}
return this._MemberListStore;
}
public get slidingSyncManager(): SlidingSyncManager {
if (!this._SlidingSyncManager) {
this._SlidingSyncManager = SlidingSyncManager.instance;

View file

@ -0,0 +1,261 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../settings/SettingsStore";
import { SdkContextClass } from "../contexts/SDKContext";
import SdkConfig from "../SdkConfig";
// 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;
/**
* A class for storing application state for MemberList.
*/
export class MemberListStore {
// cache of Display Name -> name to sort based on. This strips out special symbols like @.
private readonly sortNames = new Map<string, string>();
// list of room IDs that have been lazy loaded
private readonly loadedRooms = new Set<string>;
private collator?: Intl.Collator;
public constructor(
private readonly stores: SdkContextClass,
) {
}
/**
* Load the member list. Call this whenever the list may have changed.
* @param roomId The room to load the member list in
* @param searchQuery Optional search query to filter the list.
* @returns A list of filtered and sorted room members, grouped by membership.
*/
public async loadMemberList(
roomId: string, searchQuery?: string,
): Promise<Record<"joined" | "invited", RoomMember[]>> {
if (!this.stores.client) {
return {
joined: [],
invited: [],
};
}
const language = SettingsStore.getValue("language");
this.collator = new Intl.Collator(language, { sensitivity: 'base', ignorePunctuation: false });
const members = await this.loadMembers(roomId);
// Filter then sort as it's more efficient than sorting tons of members we will just filter out later.
// Also sort each group, as there's no point comparing invited/joined users when they aren't in the same list!
const membersByMembership = this.filterMembers(members, searchQuery);
membersByMembership.joined.sort((a: RoomMember, b: RoomMember) => {
return this.sortMembers(a, b);
});
membersByMembership.invited.sort((a: RoomMember, b: RoomMember) => {
return this.sortMembers(a, b);
});
return {
joined: membersByMembership.joined,
invited: membersByMembership.invited,
};
}
private async loadMembers(roomId: string): Promise<Array<RoomMember>> {
const room = this.stores.client!.getRoom(roomId);
if (!room) {
return [];
}
if (!this.isLazyLoadingEnabled(roomId) || this.loadedRooms.has(roomId)) {
// nice and easy, we must already have all the members so just return them.
return this.loadMembersInRoom(room);
}
// lazy loading is enabled. There are two kinds of lazy loading:
// - With storage: most members are in indexedDB, we just need a small delta via /members.
// Valid for normal sync in normal windows.
// - Without storage: nothing in indexedDB, we need to load all via /members. Valid for
// Sliding Sync and incognito windows (non-Sliding Sync).
if (!this.isLazyMemberStorageEnabled()) {
// pull straight from the server. Don't use a since token as we don't have earlier deltas
// accumulated.
room.currentState.markOutOfBandMembersStarted();
const response = await this.stores.client!.members(roomId, undefined, "leave");
const memberEvents = response.chunk.map(this.stores.client!.getEventMapper());
room.currentState.setOutOfBandMembers(memberEvents);
} else {
// load using traditional lazy loading
try {
await room.loadMembersIfNeeded();
} catch (ex) {/* already logged in RoomView */}
}
// remember that we have loaded the members so we don't hit /members all the time. We
// will forget this on refresh which is fine as we only store the data in-memory.
this.loadedRooms.add(roomId);
return this.loadMembersInRoom(room);
}
private loadMembersInRoom(room: Room): Array<RoomMember> {
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 = this.stores.client!.getUser(member.userId) || undefined;
}
// 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;
}
/**
* Check if this room should be lazy loaded. Lazy loading means fetching the member list in
* a delayed or incremental fashion. It means the `Room` object doesn't have all the members.
* @param roomId The room to check if lazy loading is enabled
* @returns True if enabled
*/
private isLazyLoadingEnabled(roomId: string): boolean {
if (SettingsStore.getValue("feature_sliding_sync")) {
// only unencrypted rooms use lazy loading
return !this.stores.client!.isRoomEncrypted(roomId);
}
return this.stores.client!.hasLazyLoadMembersEnabled();
}
/**
* Check if lazy member storage is supported.
* @returns True if there is storage for lazy loading members
*/
private isLazyMemberStorageEnabled(): boolean {
if (SettingsStore.getValue("feature_sliding_sync")) {
return false;
}
return this.stores.client!.hasLazyLoadMembersEnabled();
}
public isPresenceEnabled(): boolean {
if (!this.stores.client) {
return true;
}
const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url");
return enablePresenceByHsUrl?.[this.stores.client!.baseUrl] ?? true;
}
/**
* Filter out members based on an optional search query. Groups by membership state.
* @param members The list of members to filter.
* @param query The textual query to filter based on.
* @returns An object with a list of joined and invited users respectively.
*/
private filterMembers(
members: Array<RoomMember>, query?: string,
): Record<"joined" | "invited", RoomMember[]> {
const result: Record<"joined" | "invited", RoomMember[]> = {
joined: [],
invited: [],
};
members.forEach((m) => {
if (m.membership !== "join" && m.membership !== "invite") {
return; // bail early for left/banned users
}
if (query) {
query = query.toLowerCase();
const matchesName = m.name.toLowerCase().includes(query);
const matchesId = m.userId.toLowerCase().includes(query);
if (!matchesName && !matchesId) {
return;
}
}
switch (m.membership) {
case "join":
result.joined.push(m);
break;
case "invite":
result.invited.push(m);
break;
}
});
return result;
}
/**
* Sort algorithm for room members.
* @param memberA
* @param memberB
* @returns Negative if A comes before B, 0 if A and B are equivalent, Positive is A comes after B.
*/
private sortMembers(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;
const showPresence = this.isPresenceEnabled();
// First by presence
if (showPresence) {
const convertPresence = (p: string): string => p === 'unavailable' ? 'online' : p;
const presenceIndex = (p: string): number => {
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;
}
}
// Second by power level
if (memberA.powerLevel !== memberB.powerLevel) {
return memberB.powerLevel - memberA.powerLevel;
}
// Third by last active
if (showPresence && userA!.getLastActiveTs() !== userB!.getLastActiveTs()) {
return userB!.getLastActiveTs() - userA!.getLastActiveTs();
}
// Fourth by name (alphabetical)
return this.collator!.compare(this.canonicalisedName(memberA.name), this.canonicalisedName(memberB.name));
}
/**
* Calculate the canonicalised name for the input name.
* @param name The member display name
* @returns The name to sort on
*/
private canonicalisedName(name: string): string {
let result = this.sortNames.get(name);
if (result) {
return result;
}
result = (name[0] === '@' ? name.slice(1) : name).replace(SORT_REGEX, "");
this.sortNames.set(name, result);
return result;
}
}

View file

@ -16,6 +16,7 @@ limitations under the License.
import { SlidingSync } from 'matrix-js-sdk/src/sliding-sync';
import { mocked } from 'jest-mock';
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk/src/matrix';
import { SlidingSyncManager } from '../src/SlidingSyncManager';
import { stubClient } from './test-utils';
@ -26,14 +27,57 @@ const MockSlidingSync = <jest.Mock<SlidingSync>><unknown>SlidingSync;
describe('SlidingSyncManager', () => {
let manager: SlidingSyncManager;
let slidingSync: SlidingSync;
let client: MatrixClient;
beforeEach(() => {
slidingSync = new MockSlidingSync();
manager = new SlidingSyncManager();
manager.configure(stubClient(), "invalid");
client = stubClient();
// by default the client has no rooms: stubClient magically makes rooms annoyingly.
mocked(client.getRoom).mockReturnValue(null);
manager.configure(client, "invalid");
manager.slidingSync = slidingSync;
});
describe("setRoomVisible", () => {
it("adds a subscription for the room", async () => {
const roomId = "!room:id";
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
mocked(slidingSync.modifyRoomSubscriptions).mockResolvedValue("yep");
await manager.setRoomVisible(roomId, true);
expect(slidingSync.modifyRoomSubscriptions).toBeCalledWith(new Set<string>([roomId]));
});
it("adds a custom subscription for a lazy-loadable room", async () => {
const roomId = "!lazy:id";
const room = new Room(roomId, client, client.getUserId());
room.getLiveTimeline().initialiseState([
new MatrixEvent({
type: "m.room.create",
state_key: "",
event_id: "$abc123",
sender: client.getUserId(),
content: {
creator: client.getUserId(),
},
}),
]);
mocked(client.getRoom).mockImplementation((r: string): Room => {
if (roomId === r) {
return room;
}
return null;
});
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
mocked(slidingSync.modifyRoomSubscriptions).mockResolvedValue("yep");
await manager.setRoomVisible(roomId, true);
expect(slidingSync.modifyRoomSubscriptions).toBeCalledWith(new Set<string>([roomId]));
// we aren't prescriptive about what the sub name is.
expect(slidingSync.useCustomSubscription).toBeCalledWith(roomId, expect.anything());
});
});
describe("startSpidering", () => {
it("requests in batchSizes", async () => {
const gapMs = 1;

View file

@ -24,7 +24,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import _RightPanel from "../../../src/components/structures/RightPanel";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { stubClient, wrapInMatrixClientContext, mkRoom } from "../../test-utils";
import { stubClient, wrapInMatrixClientContext, mkRoom, wrapInSdkContext } from "../../test-utils";
import { Action } from "../../../src/dispatcher/actions";
import dis from "../../../src/dispatcher/dispatcher";
import DMRoomMap from "../../../src/utils/DMRoomMap";
@ -35,17 +35,23 @@ import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import RoomSummaryCard from "../../../src/components/views/right_panel/RoomSummaryCard";
import MemberList from "../../../src/components/views/rooms/MemberList";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
const RightPanel = wrapInMatrixClientContext(_RightPanel);
const RightPanelBase = wrapInMatrixClientContext(_RightPanel);
describe("RightPanel", () => {
const resizeNotifier = new ResizeNotifier();
let cli: MockedObject<MatrixClient>;
let context: SdkContextClass;
let RightPanel: React.ComponentType<React.ComponentProps<typeof RightPanelBase>>;
beforeEach(() => {
stubClient();
cli = mocked(MatrixClientPeg.get());
DMRoomMap.makeShared();
context = new SdkContextClass();
context.client = cli;
RightPanel = wrapInSdkContext(RightPanelBase, context);
});
afterEach(async () => {

View file

@ -1,5 +1,6 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,7 +27,8 @@ import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
import * as TestUtils from '../../../test-utils';
import MemberList from "../../../../src/components/views/rooms/MemberList";
import MemberTile from '../../../../src/components/views/rooms/MemberTile';
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { SDKContext } from '../../../../src/contexts/SDKContext';
import { TestSdkContext } from '../../../TestSdkContext';
function generateRoomId() {
return '!' + Math.random().toString().slice(2, 10) + ':domain';
@ -116,9 +118,11 @@ describe('MemberList', () => {
const gatherWrappedRef = (r) => {
memberList = r;
};
const context = new TestSdkContext();
context.client = client;
root = ReactDOM.render(
(
<MatrixClientContext.Provider value={client}>
<SDKContext.Provider value={context}>
<MemberList
searchQuery=""
onClose={jest.fn()}
@ -126,7 +130,7 @@ describe('MemberList', () => {
roomId={memberListRoom.roomId}
ref={gatherWrappedRef}
/>
</MatrixClientContext.Provider>
</SDKContext.Provider>
),
parentDiv,
);

View file

@ -0,0 +1,235 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { mocked } from "jest-mock";
import { EventType, IContent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import SdkConfig from "../../src/SdkConfig";
import SettingsStore from "../../src/settings/SettingsStore";
import { MemberListStore } from "../../src/stores/MemberListStore";
import { stubClient } from "../test-utils";
import { TestSdkContext } from "../TestSdkContext";
describe("MemberListStore", () => {
const alice = "@alice:bar";
const bob = "@bob:bar";
const charlie = "@charlie:bar";
const roomId = "!foo:bar";
let store: MemberListStore;
let client: MatrixClient;
let room: Room;
beforeEach(() => {
const context = new TestSdkContext();
client = stubClient();
client.baseUrl = "https://invalid.base.url.here";
context.client = client;
store = new MemberListStore(context);
// alice is joined to the room.
room = new Room(roomId, client, client.getUserId()!);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomCreate,
state_key: "",
content: {
creator: alice,
},
sender: alice,
room_id: roomId,
event_id: "$1",
}),
new MatrixEvent({
type: EventType.RoomMember,
state_key: alice,
content: {
membership: "join",
},
sender: alice,
room_id: roomId,
event_id: "$2",
}),
]);
room.recalculate();
mocked(client.getRoom).mockImplementation((r: string): Room | null => {
if (r === roomId) {
return room;
}
return null;
});
SdkConfig.put({
enable_presence_by_hs_url: {
[client.baseUrl]: false,
},
});
});
it("loads members in a room", async () => {
addMember(room, bob, "invite");
addMember(room, charlie, "leave");
const { invited, joined } = await store.loadMemberList(roomId);
expect(invited).toEqual([room.getMember(bob)]);
expect(joined).toEqual([room.getMember(alice)]);
});
it("fails gracefully for invalid rooms", async () => {
const { invited, joined } = await store.loadMemberList("!idontexist:bar");
expect(invited).toEqual([]);
expect(joined).toEqual([]);
});
it("sorts by power level", async () => {
addMember(room, bob, "join");
addMember(room, charlie, "join");
setPowerLevels(room, {
users: {
[alice]: 100,
[charlie]: 50,
},
users_default: 10,
});
const { invited, joined } = await store.loadMemberList(roomId);
expect(invited).toEqual([]);
expect(joined).toEqual([room.getMember(alice), room.getMember(charlie), room.getMember(bob)]);
});
it("sorts by name if power level is equal", async () => {
const doris = "@doris:bar";
addMember(room, bob, "join");
addMember(room, charlie, "join");
setPowerLevels(room, {
users_default: 10,
});
let { invited, joined } = await store.loadMemberList(roomId);
expect(invited).toEqual([]);
expect(joined).toEqual([room.getMember(alice), room.getMember(bob), room.getMember(charlie)]);
// Ensure it sorts by display name if they are set
addMember(room, doris, "join", "AAAAA");
({ invited, joined } = await store.loadMemberList(roomId));
expect(invited).toEqual([]);
expect(joined).toEqual(
[room.getMember(doris), room.getMember(alice), room.getMember(bob), room.getMember(charlie)],
);
});
it("filters based on a search query", async () => {
const mice = "@mice:bar";
const zorro = "@zorro:bar";
addMember(room, bob, "join");
addMember(room, mice, "join");
let { invited, joined } = await store.loadMemberList(roomId, "ice");
expect(invited).toEqual([]);
expect(joined).toEqual([room.getMember(alice), room.getMember(mice)]);
// Ensure it filters by display name if they are set
addMember(room, zorro, "join", "ice ice baby");
({ invited, joined } = await store.loadMemberList(roomId, "ice"));
expect(invited).toEqual([]);
expect(joined).toEqual([room.getMember(alice), room.getMember(zorro), room.getMember(mice)]);
});
describe("lazy loading", () => {
beforeEach(() => {
mocked(client.hasLazyLoadMembersEnabled).mockReturnValue(true);
room.loadMembersIfNeeded = jest.fn();
mocked(room.loadMembersIfNeeded).mockResolvedValue(true);
});
it("calls Room.loadMembersIfNeeded once when enabled", async () => {
let { joined } = await store.loadMemberList(roomId);
expect(joined).toEqual([room.getMember(alice)]);
expect(room.loadMembersIfNeeded).toHaveBeenCalledTimes(1);
({ joined } = await store.loadMemberList(roomId));
expect(joined).toEqual([room.getMember(alice)]);
expect(room.loadMembersIfNeeded).toHaveBeenCalledTimes(1);
});
});
describe("sliding sync", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, 'getValue').mockImplementation((settingName, roomId, value) => {
return settingName === "feature_sliding_sync"; // this is enabled, everything else is disabled.
});
client.members = jest.fn();
});
it("calls /members when lazy loading", async () => {
mocked(client.members).mockResolvedValue({
chunk: [
{
type: EventType.RoomMember,
state_key: bob,
content: {
membership: "join",
displayname: "Bob",
},
sender: bob,
room_id: room.roomId,
event_id: "$" + Math.random(),
origin_server_ts: 2,
},
],
});
const { joined } = await store.loadMemberList(roomId);
expect(joined).toEqual([room.getMember(alice), room.getMember(bob)]);
expect(client.members).toHaveBeenCalled();
});
it("does not use lazy loading on encrypted rooms", async () => {
client.isRoomEncrypted = jest.fn();
mocked(client.isRoomEncrypted).mockReturnValue(true);
const { joined } = await store.loadMemberList(roomId);
expect(joined).toEqual([room.getMember(alice)]);
expect(client.members).not.toHaveBeenCalled();
});
});
});
function addEventToRoom(room: Room, ev: MatrixEvent) {
room.getLiveTimeline().addEvent(ev, {
toStartOfTimeline: false,
});
}
function setPowerLevels(room: Room, pl: IContent) {
addEventToRoom(room, new MatrixEvent({
type: EventType.RoomPowerLevels,
state_key: "",
content: pl,
sender: room.getCreator()!,
room_id: room.roomId,
event_id: "$" + Math.random(),
}));
}
function addMember(room: Room, userId: string, membership: string, displayName?: string) {
addEventToRoom(room, new MatrixEvent({
type: EventType.RoomMember,
state_key: userId,
content: {
membership: membership,
displayname: displayName,
},
sender: userId,
room_id: room.roomId,
event_id: "$" + Math.random(),
}));
}