element-web/src/stores/MemberListStore.ts

253 lines
10 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
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.loadedRooms.has(roomId) || !(await this.isLazyLoadingEnabled(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, KnownMembership.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 {
/* 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 async isLazyLoadingEnabled(roomId: string): Promise<boolean> {
if (SettingsStore.getValue("feature_sliding_sync")) {
// only unencrypted rooms use lazy loading
return !(await this.stores.client?.getCrypto()?.isEncryptionEnabledInRoom(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 !== KnownMembership.Join && m.membership !== KnownMembership.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 KnownMembership.Join:
result.joined.push(m);
break;
case KnownMembership.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;
}
}