diff --git a/package.json b/package.json index bc79b791d4..bae7b1e4cf 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "jest": { "testEnvironment": "./__test-utils__/environment.js", "testMatch": [ - "/test/**/*-test.[jt]s" + "/test/**/*-test.[jt]s?(x)" ], "setupFiles": [ "jest-canvas-mock" diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.tsx similarity index 64% rename from src/components/views/rooms/MemberList.js rename to src/components/views/rooms/MemberList.tsx index cb50f0fff3..5ebe5bea59 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.tsx @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2017, 2018 New Vector Ltd +Copyright 2021 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,17 +21,28 @@ import React from 'react'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import dis from '../../../dispatcher/dispatcher'; -import {isValid3pidInvite} from "../../../RoomInvite"; -import rate_limited_func from "../../../ratelimitedfunc"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import * as sdk from "../../../index"; -import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; +import { isValid3pidInvite } from "../../../RoomInvite"; +import rateLimitedFunction from "../../../ratelimitedfunc"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import BaseCard from "../right_panel/BaseCard"; -import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +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'; const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_INVITED = 5; @@ -40,41 +52,59 @@ const SHOW_MORE_INCREMENT = 100; // matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g; +interface IProps { + roomId: string; + onClose(): void; +} + +interface IState { + loading: boolean; + members: Array; + filteredJoinedMembers: Array; + filteredInvitedMembers: Array; + canInvite: boolean; + truncateAtJoined: number; + truncateAtInvited: number; + searchQuery: string; +} + @replaceableComponent("views.rooms.MemberList") -export default class MemberList extends React.Component { +export default class MemberList extends React.Component { + private showPresence = true; + private mounted = false; + private collator: Intl.Collator; + private sortNames = new Map(); // RoomMember -> sortName + constructor(props) { super(props); const cli = MatrixClientPeg.get(); if (cli.hasLazyLoadMembersEnabled()) { // show an empty list - this.state = this._getMembersState([]); + this.state = this.getMembersState([]); } else { - this.state = this._getMembersState(this.roomMembers()); + this.state = this.getMembersState(this.roomMembers()); } 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 = true; - if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) { - this._showPresence = enablePresenceByHsUrl[hsUrl]; - } + this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true; } // eslint-disable-next-line camelcase UNSAFE_componentWillMount() { const cli = MatrixClientPeg.get(); - this._mounted = true; + this.mounted = true; if (cli.hasLazyLoadMembersEnabled()) { - this._showMembersAccordingToMembershipWithLL(); + this.showMembersAccordingToMembershipWithLL(); cli.on("Room.myMembership", this.onMyMembership); } else { - this._listenForMembersChanges(); + this.listenForMembersChanges(); } } - _listenForMembersChanges() { + private listenForMembersChanges(): void { const cli = MatrixClientPeg.get(); cli.on("RoomState.members", this.onRoomStateMember); cli.on("RoomMember.name", this.onRoomMemberName); @@ -89,7 +119,7 @@ export default class MemberList extends React.Component { } componentWillUnmount() { - this._mounted = false; + this.mounted = false; const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener("RoomState.members", this.onRoomStateMember); @@ -103,7 +133,7 @@ export default class MemberList extends React.Component { } // cancel any pending calls to the rate_limited_funcs - this._updateList.cancelPendingCall(); + this.updateList.cancelPendingCall(); } /** @@ -111,7 +141,7 @@ export default class MemberList extends React.Component { * show a spinner and load the members if the user is joined, * or show the members available so far if the user is invited */ - async _showMembersAccordingToMembershipWithLL() { + private async showMembersAccordingToMembershipWithLL(): Promise { const cli = MatrixClientPeg.get(); if (cli.hasLazyLoadMembersEnabled()) { const cli = MatrixClientPeg.get(); @@ -122,31 +152,31 @@ export default class MemberList extends React.Component { try { await room.loadMembersIfNeeded(); } catch (ex) {/* already logged in RoomView */} - if (this._mounted) { - this.setState(this._getMembersState(this.roomMembers())); - this._listenForMembersChanges(); + 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())); + this.setState(this.getMembersState(this.roomMembers())); } } } - get canInvite() { + private get canInvite(): boolean { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.roomId); return room && room.canInvite(cli.getUserId()); } - _getMembersState(members) { - // set the state after determining _showPresence to make sure it's - // taken into account while rerendering + private getMembersState(members: Array): IState { + // set the state after determining showPresence to make sure it's + // taken into account while rendering return { loading: false, members: members, - filteredJoinedMembers: this._filterMembers(members, 'join'), - filteredInvitedMembers: this._filterMembers(members, 'invite'), + filteredJoinedMembers: this.filterMembers(members, 'join'), + filteredInvitedMembers: this.filterMembers(members, 'invite'), canInvite: this.canInvite, // ideally we'd size this to the page height, but @@ -157,72 +187,72 @@ export default class MemberList extends React.Component { }; } - onUserPresenceChange = (event, user) => { + private onUserPresenceChange = (event: MatrixEvent, user: User): void => { // 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 + this.updateList(); // reorder the membership list } }; - onRoom = room => { + private onRoom = (room: Room): void => { 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(); + this.showMembersAccordingToMembershipWithLL(); }; - onMyMembership = (room, membership, oldMembership) => { + private onMyMembership = (room: Room, membership: string, oldMembership: string): void => { if (room.roomId === this.props.roomId && membership === "join") { - this._showMembersAccordingToMembershipWithLL(); + this.showMembersAccordingToMembershipWithLL(); } }; - onRoomStateMember = (ev, state, member) => { + private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember): void => { if (member.roomId !== this.props.roomId) { return; } - this._updateList(); + this.updateList(); }; - onRoomMemberName = (ev, member) => { + private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => { if (member.roomId !== this.props.roomId) { return; } - this._updateList(); + this.updateList(); }; - onRoomStateEvent = (event, state) => { + private onRoomStateEvent = (event: MatrixEvent, state: RoomState): void => { if (event.getRoomId() === this.props.roomId && event.getType() === "m.room.third_party_invite") { - this._updateList(); + this.updateList(); } if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite }); }; - _updateList = rate_limited_func(() => { - this._updateListNow(); + private updateList = rateLimitedFunction(() => { + this.updateListNow(); }, 500); - _updateListNow() { - // console.log("Updating memberlist"); - const newState = { + private updateListNow(): void { + const members = this.roomMembers() + + this.setState({ loading: false, - members: this.roomMembers(), - }; - newState.filteredJoinedMembers = this._filterMembers(newState.members, 'join', this.state.searchQuery); - newState.filteredInvitedMembers = this._filterMembers(newState.members, 'invite', this.state.searchQuery); - this.setState(newState); + members: members, + filteredJoinedMembers: this.filterMembers(members, 'join', this.state.searchQuery), + filteredInvitedMembers: this.filterMembers(members, 'invite', this.state.searchQuery), + }); } - getMembersWithUser() { + private getMembersWithUser(): Array { if (!this.props.roomId) return []; const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.roomId); @@ -230,15 +260,18 @@ export default class MemberList extends React.Component { const allMembers = Object.values(room.currentState.members); - allMembers.forEach(function(member) { + 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 + // before the user object exists. This may or may not cause // https://github.com/vector-im/vector-web/issues/186 - if (member.user === null) { + if (!member.user) { member.user = cli.getUser(member.userId); } - member.sortName = (member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, ""); + 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 @@ -247,7 +280,7 @@ export default class MemberList extends React.Component { return allMembers; } - roomMembers() { + private roomMembers(): Array { const allMembers = this.getMembersWithUser(); const filteredAndSortedMembers = allMembers.filter((m) => { return ( @@ -255,23 +288,21 @@ export default class MemberList extends React.Component { ); }); const language = SettingsStore.getValue("language"); - this.collator = new Intl.Collator(language, { sensitivity: 'base', usePunctuation: true }); + this.collator = new Intl.Collator(language, { sensitivity: 'base', ignorePunctuation: false }); filteredAndSortedMembers.sort(this.memberSort); return filteredAndSortedMembers; } - _createOverflowTileJoined = (overflowCount, totalCount) => { - return this._createOverflowTile(overflowCount, totalCount, this._showMoreJoinedMemberList); + private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => { + return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList); }; - _createOverflowTileInvited = (overflowCount, totalCount) => { - return this._createOverflowTile(overflowCount, totalCount, this._showMoreInvitedMemberList); + private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => { + return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList); }; - _createOverflowTile = (overflowCount, totalCount, onClick) => { + 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. - const EntityTile = sdk.getComponent("rooms.EntityTile"); - const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); const text = _t("and %(count)s others...", { count: overflowCount }); return ( { + private showMoreJoinedMemberList = (): void => { this.setState({ truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT, }); }; - _showMoreInvitedMemberList = () => { + private showMoreInvitedMemberList = (): void => { this.setState({ truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT, }); }; - memberString(member) { + /** + * 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 : "") + ", " + (u ? u.getLastActiveTs() : "") + ", " + (u ? u.currentlyActive : "") + ", " + (u ? u.presence : "") + ")"; + return ( + "(" + + member.name + + ", " + + member.powerLevel + + ", " + + (u ? u.lastActiveAgo : "") + + ", " + + (u ? u.getLastActiveTs() : "") + + ", " + + (u ? u.currentlyActive : "") + + ", " + + (u ? u.presence : "") + + ")" + ); } } // returns negative if a comes before b, // returns 0 if a and b are equivalent in ordering // returns positive if a comes after b. - memberSort = (memberA, memberB) => { + private memberSort = (memberA: RoomMember, memberB: RoomMember): number => { // order by presence, with "active now" first. // ...and then by power level // ...and then by last active @@ -325,7 +373,7 @@ export default class MemberList extends React.Component { if (!userA && userB) return 1; // First by presence - if (this._showPresence) { + if (this.showPresence) { const convertPresence = (p) => p === 'unavailable' ? 'online' : p; const presenceIndex = p => { const order = ['active', 'online', 'offline']; @@ -349,31 +397,31 @@ export default class MemberList extends React.Component { } // Third by last active - if (this._showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) { + 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(memberA.sortName, memberB.sortName); + return this.collator.compare(this.sortNames.get(memberA), this.sortNames.get(memberB)); }; - onSearchQueryChanged = searchQuery => { + private onSearchQueryChanged = (searchQuery: string): void => { this.setState({ searchQuery, - filteredJoinedMembers: this._filterMembers(this.state.members, 'join', searchQuery), - filteredInvitedMembers: this._filterMembers(this.state.members, 'invite', searchQuery), + filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery), + filteredInvitedMembers: this.filterMembers(this.state.members, 'invite', searchQuery), }); }; - _onPending3pidInviteClick = inviteEvent => { + private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => { dis.dispatch({ action: 'view_3pid_invite', event: inviteEvent, }); }; - _filterMembers(members, membership, query) { + private filterMembers(members: Array, membership: string, query?: string): Array { return members.filter((m) => { if (query) { query = query.toLowerCase(); @@ -389,7 +437,7 @@ export default class MemberList extends React.Component { }); } - _getPending3PidInvites() { + private getPending3PidInvites(): Array { // 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 @@ -409,42 +457,40 @@ export default class MemberList extends React.Component { } } - _makeMemberTiles(members) { - const MemberTile = sdk.getComponent("rooms.MemberTile"); - const EntityTile = sdk.getComponent("rooms.EntityTile"); - + private makeMemberTiles(members: Array) { return members.map((m) => { - if (m.userId) { + if (m instanceof RoomMember) { // Is a Matrix invite - return ; + return ; } else { // Is a 3pid invite return this._onPending3pidInviteClick(m)} />; + onClick={() => this.onPending3pidInviteClick(m)} />; } }); } - _getChildrenJoined = (start, end) => this._makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end)); - - _getChildCountJoined = () => this.state.filteredJoinedMembers.length; - - _getChildrenInvited = (start, end) => { - let targets = this.state.filteredInvitedMembers; - if (end > this.state.filteredInvitedMembers.length) { - targets = targets.concat(this._getPending3PidInvites()); - } - - return this._makeMemberTiles(targets.slice(start, end)); + private getChildrenJoined = (start: number, end: number): Array => { + return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end)) }; - _getChildCountInvited = () => { - return this.state.filteredInvitedMembers.length + (this._getPending3PidInvites() || []).length; + private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length; + + private getChildrenInvited = (start: number, end: number): Array => { + let targets = this.state.filteredInvitedMembers; + if (end > this.state.filteredInvitedMembers.length) { + targets = targets.concat(this.getPending3PidInvites()); + } + + return this.makeMemberTiles(targets.slice(start, end)); + }; + + private getChildCountInvited = (): number => { + return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length; } render() { if (this.state.loading) { - const Spinner = sdk.getComponent("elements.Spinner"); return ; } - const SearchBox = sdk.getComponent('structures.SearchBox'); - const TruncatedList = sdk.getComponent("elements.TruncatedList"); - const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.roomId); let inviteButton; @@ -470,22 +513,30 @@ export default class MemberList extends React.Component { inviteButtonText = _t("Invite to this space"); } - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - inviteButton = - + inviteButton = ( + { inviteButtonText } - ; + + ); } let invitedHeader; let invitedSection; - if (this._getChildCountInvited() > 0) { + if (this.getChildCountInvited() > 0) { invitedHeader =

{ _t("Invited") }

; - invitedSection = ; + invitedSection = ( + + ); } const footer = ( @@ -517,17 +568,19 @@ export default class MemberList extends React.Component { previousPhase={previousPhase} >
- + { invitedHeader } { invitedSection }
; } - onInviteButtonClick = () => { + onInviteButtonClick = (): void => { if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'require_registration'}); return; diff --git a/test/components/views/rooms/MemberList-test.js b/test/components/views/rooms/MemberList-test.tsx similarity index 88% rename from test/components/views/rooms/MemberList-test.js rename to test/components/views/rooms/MemberList-test.tsx index 28fead770c..8012c43c4b 100644 --- a/test/components/views/rooms/MemberList-test.js +++ b/test/components/views/rooms/MemberList-test.tsx @@ -1,21 +1,36 @@ +/* +Copyright 2021 Šimon Brandner + +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 ReactTestUtils from 'react-dom/test-utils'; import ReactDOM from 'react-dom'; import * as TestUtils from '../../../test-utils'; - -import {MatrixClientPeg} from '../../../../src/MatrixClientPeg'; import sdk from '../../../skinned-sdk'; - -import {Room, RoomMember, User} from 'matrix-js-sdk'; - +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; +import { User } from "matrix-js-sdk/src/models/user"; import { compare } from "../../../../src/utils/strings"; +import MemberList from "../../../../src/components/views/rooms/MemberList"; function generateRoomId() { return '!' + Math.random().toString().slice(2, 10) + ':domain'; } - describe('MemberList', () => { function createRoom(opts) { const room = new Room(generateRoomId(), null, client.getUserId()); @@ -97,13 +112,19 @@ describe('MemberList', () => { memberListRoom.currentState.members[member.userId] = member; } - const MemberList = sdk.getComponent('views.rooms.MemberList'); const WrappedMemberList = TestUtils.wrapInMatrixClientContext(MemberList); const gatherWrappedRef = (r) => { memberList = r; }; - root = ReactDOM.render(, parentDiv); + root = ReactDOM.render( + ( + + ), + parentDiv, + ); }); afterEach((done) => { @@ -213,8 +234,8 @@ describe('MemberList', () => { }); // Bypass all the event listeners and skip to the good part - memberList._showPresence = enablePresence; - memberList._updateListNow(); + memberList.showPresence = enablePresence; + memberList.updateListNow(); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); @@ -225,7 +246,7 @@ describe('MemberList', () => { // Bypass all the event listeners and skip to the good part memberList._showPresence = enablePresence; - memberList._updateListNow(); + memberList.updateListNow(); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); @@ -254,8 +275,8 @@ describe('MemberList', () => { }); // Bypass all the event listeners and skip to the good part - memberList._showPresence = enablePresence; - memberList._updateListNow(); + memberList.showPresence = enablePresence; + memberList.updateListNow(); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); @@ -273,8 +294,8 @@ describe('MemberList', () => { }); // Bypass all the event listeners and skip to the good part - memberList._showPresence = enablePresence; - memberList._updateListNow(); + memberList.showPresence = enablePresence; + memberList.updateListNow(); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);