Implement new unreachable state and fix broken string ref (#11748)

* Fix string ref issue

* Implement unreachable state

* Fix eslint failure

* Fix i18n

* Fix i18n again

* Write cypress test

* Write jest test

* Write more jest tests

* Update method name

* Use unstable prefix

* Always use prefix

This is never to going to be in the spec so always use the io.element
prefix

* Update tests

* Remove redundant code from cypress test

* Use unstable prefix

* Refactor code

* Remove supressOnHover prop

* Remove sub-text label

* Join lines

* Remove blank line

* Add jsdoc
This commit is contained in:
R Midhun Suresh 2023-11-07 15:44:30 +05:30 committed by GitHub
parent 6849afd9fc
commit 90419bdffd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 234 additions and 133 deletions

View file

@ -0,0 +1,64 @@
/*
Copyright 2023 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.
*/
/// <reference types="cypress" />
import { HomeserverInstance } from "../../plugins/utils/homeserver";
describe("Presence tests", () => {
let homeserver: HomeserverInstance;
beforeEach(() => {
cy.startHomeserver("default").then((data) => {
homeserver = data;
});
});
afterEach(() => {
cy.stopHomeserver(homeserver);
});
it("renders unreachable presence state correctly", () => {
cy.initTestUser(homeserver, "Janet");
cy.getBot(homeserver, { displayName: "Bob" }).then((bob) => {
cy.intercept("GET", "**/sync*", (req) => {
req.continue((res) => {
res.body.presence = {
events: [
{
type: "m.presence",
sender: bob.getUserId(),
content: {
presence: "io.element.unreachable",
currently_active: false,
},
},
],
};
});
});
cy.createRoom({ name: "My Room", invite: [bob.getUserId()] }).then((roomId) => {
cy.viewRoomById(roomId);
});
cy.findByRole("button", { name: "Room info" }).click();
cy.get(".mx_RightPanel").within(() => {
cy.contains("People").click();
});
cy.get(".mx_EntityTile_unreachable")
.should("contain.text", "Bob")
.should("contain.text", "User's server unreachable");
});
});
});

View file

@ -46,11 +46,11 @@ limitations under the License.
background-color: $header-panel-text-primary-color;
}
.mx_EntityTile .mx_PresenceLabel {
.mx_EntityTile:not(.mx_EntityTile_unreachable) .mx_PresenceLabel {
display: none;
}
.mx_EntityTile:not(.mx_EntityTile_noHover):hover .mx_PresenceLabel {
.mx_EntityTile:hover .mx_PresenceLabel {
display: block;
}
@ -106,7 +106,9 @@ limitations under the License.
}
.mx_EntityTile_unknown .mx_EntityTile_avatar,
.mx_EntityTile_unknown .mx_EntityTile_name {
.mx_EntityTile_unknown .mx_EntityTile_name,
.mx_EntityTile_unreachable .mx_EntityTile_avatar,
.mx_EntityTile_unreachable .mx_EntityTile_name {
opacity: 0.25;
}

View file

@ -264,8 +264,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
<BaseAvatar url={require("../../../../res/img/ellipsis.svg").default} name="..." size="36px" />
}
name={text}
presenceState="online"
suppressOnHover={true}
showPresence={false}
onClick={() => setTruncateAt(totalCount)}
/>
);

View file

@ -35,12 +35,13 @@ const PowerLabel: Record<PowerStatus, TranslationKey> = {
[PowerStatus.Moderator]: _td("power_level|mod"),
};
export type PresenceState = "offline" | "online" | "unavailable";
export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable";
const PRESENCE_CLASS: Record<PresenceState, string> = {
offline: "mx_EntityTile_offline",
online: "mx_EntityTile_online",
unavailable: "mx_EntityTile_unavailable",
"offline": "mx_EntityTile_offline",
"online": "mx_EntityTile_online",
"unavailable": "mx_EntityTile_unavailable",
"io.element.unreachable": "mx_EntityTile_unreachable",
};
function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string {
@ -75,7 +76,6 @@ interface IProps {
presenceCurrentlyActive?: boolean;
showInviteButton: boolean;
onClick(): void;
suppressOnHover: boolean;
showPresence: boolean;
subtextLabel?: string;
e2eStatus?: E2EState;
@ -93,7 +93,6 @@ export default class EntityTile extends React.PureComponent<IProps, IState> {
presenceLastActiveAgo: 0,
presenceLastTs: 0,
showInviteButton: false,
suppressOnHover: false,
showPresence: true,
};
@ -105,10 +104,27 @@ export default class EntityTile extends React.PureComponent<IProps, IState> {
};
}
/**
* Creates the PresenceLabel component if needed
* @returns The PresenceLabel component if we need to render it, undefined otherwise
*/
private getPresenceLabel(): JSX.Element | undefined {
if (!this.props.showPresence) return;
const activeAgo = this.props.presenceLastActiveAgo
? Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo)
: -1;
return (
<PresenceLabel
activeAgo={activeAgo}
currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState}
/>
);
}
public render(): React.ReactNode {
const mainClassNames: Record<string, boolean> = {
mx_EntityTile: true,
mx_EntityTile_noHover: !!this.props.suppressOnHover,
};
if (this.props.className) mainClassNames[this.props.className] = true;
@ -119,43 +135,13 @@ export default class EntityTile extends React.PureComponent<IProps, IState> {
);
mainClassNames[presenceClass] = true;
let nameEl;
const name = this.props.nameJSX || this.props.name;
if (!this.props.suppressOnHover) {
const activeAgo = this.props.presenceLastActiveAgo
? Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo)
: -1;
let presenceLabel: JSX.Element | undefined;
if (this.props.showPresence) {
presenceLabel = (
<PresenceLabel
activeAgo={activeAgo}
currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState}
/>
);
}
if (this.props.subtextLabel) {
presenceLabel = <span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>;
}
nameEl = (
<div className="mx_EntityTile_details">
<div className="mx_EntityTile_name">{name}</div>
{presenceLabel}
</div>
);
} else if (this.props.subtextLabel) {
nameEl = (
<div className="mx_EntityTile_details">
<div className="mx_EntityTile_name">{name}</div>
<span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>
</div>
);
} else {
nameEl = <div className="mx_EntityTile_name">{name}</div>;
}
const nameAndPresence = (
<div className="mx_EntityTile_details">
<div className="mx_EntityTile_name">{name}</div>
{this.getPresenceLabel()}
</div>
);
let inviteButton;
if (this.props.showInviteButton) {
@ -198,7 +184,7 @@ export default class EntityTile extends React.PureComponent<IProps, IState> {
{av}
{e2eIcon}
</div>
{nameEl}
{nameAndPresence}
{powerLabel}
{inviteButton}
</AccessibleButton>

View file

@ -81,6 +81,7 @@ export default class MemberList extends React.Component<IProps, IState> {
public static contextType = SDKContext;
public context!: React.ContextType<typeof SDKContext>;
private tiles: Map<string, MemberTile> = new Map();
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props);
@ -154,7 +155,7 @@ export default class MemberList extends React.Component<IProps, IState> {
// 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];
const tile = this.tiles.get(user.userId);
if (tile) {
this.updateList(); // reorder the membership list
}
@ -245,8 +246,7 @@ export default class MemberList extends React.Component<IProps, IState> {
<BaseAvatar url={require("../../../../res/img/ellipsis.svg").default} name="..." size="36px" />
}
name={text}
presenceState="online"
suppressOnHover={true}
showPresence={false}
onClick={onClick}
/>
);
@ -307,14 +307,24 @@ export default class MemberList extends React.Component<IProps, IState> {
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} />;
return (
<MemberTile
key={m.userId}
member={m}
ref={(tile) => {
if (tile) this.tiles.set(m.userId, tile);
else this.tiles.delete(m.userId);
}}
showPresence={this.showPresence}
/>
);
} else {
// Is a 3pid invite
return (
<EntityTile
key={m.getStateKey()}
name={m.getContent().display_name}
suppressOnHover={true}
showPresence={false}
onClick={() => this.onPending3pidInviteClick(m)}
/>
);

View file

@ -44,6 +44,8 @@ export default class PresenceLabel extends React.Component<IProps> {
// the 'active ago' ends up being 0.
if (presence && BUSY_PRESENCE_NAME.matches(presence)) return _t("presence|busy");
if (presence === "io.element.unreachable") return _t("presence|unreachable");
if (!currentlyActive && activeAgo !== undefined && activeAgo > 0) {
const duration = formatDuration(activeAgo);
if (presence === "online") return _t("presence|online_for", { duration: duration });

View file

@ -1740,7 +1740,8 @@
"online": "Online",
"online_for": "Online for %(duration)s",
"unknown": "Unknown",
"unknown_for": "Unknown for %(duration)s"
"unknown_for": "Unknown for %(duration)s",
"unreachable": "User's server unreachable"
},
"quick_settings": {
"all_settings": "All settings",

View file

@ -16,8 +16,8 @@ limitations under the License.
*/
import React from "react";
import { act, render, RenderResult } from "@testing-library/react";
import { Room, MatrixClient, RoomState, RoomMember, User } from "matrix-js-sdk/src/matrix";
import { act, render, RenderResult, screen } from "@testing-library/react";
import { Room, MatrixClient, RoomState, RoomMember, User, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { compare } from "matrix-js-sdk/src/utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
@ -137,84 +137,88 @@ describe("MemberList", () => {
}
}
function renderMemberList(enablePresence: boolean): void {
TestUtils.stubClient();
client = MatrixClientPeg.safeGet();
client.hasLazyLoadMembersEnabled = () => false;
// Make room
memberListRoom = createRoom();
expect(memberListRoom.roomId).toBeTruthy();
// Make users
adminUsers = [];
moderatorUsers = [];
defaultUsers = [];
const usersPerLevel = 2;
for (let i = 0; i < usersPerLevel; i++) {
const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`);
adminUser.membership = "join";
adminUser.powerLevel = 100;
adminUser.user = User.createUser(adminUser.userId, client);
adminUser.user.currentlyActive = true;
adminUser.user.presence = "online";
adminUser.user.lastPresenceTs = 1000;
adminUser.user.lastActiveAgo = 10;
adminUsers.push(adminUser);
const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`);
moderatorUser.membership = "join";
moderatorUser.powerLevel = 50;
moderatorUser.user = User.createUser(moderatorUser.userId, client);
moderatorUser.user.currentlyActive = true;
moderatorUser.user.presence = "online";
moderatorUser.user.lastPresenceTs = 1000;
moderatorUser.user.lastActiveAgo = 10;
moderatorUsers.push(moderatorUser);
const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`);
defaultUser.membership = "join";
defaultUser.powerLevel = 0;
defaultUser.user = User.createUser(defaultUser.userId, client);
defaultUser.user.currentlyActive = true;
defaultUser.user.presence = "online";
defaultUser.user.lastPresenceTs = 1000;
defaultUser.user.lastActiveAgo = 10;
defaultUsers.push(defaultUser);
}
client.getRoom = (roomId) => {
if (roomId === memberListRoom.roomId) return memberListRoom;
else return null;
};
memberListRoom.currentState = {
members: {},
getMember: jest.fn(),
getStateEvents: ((eventType, stateKey) =>
stateKey === undefined ? [] : null) as RoomState["getStateEvents"], // ignore 3pid invites
} as unknown as RoomState;
for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) {
memberListRoom.currentState.members[member.userId] = member;
}
const gatherWrappedRef = (r: MemberList) => {
memberList = r;
};
const context = new TestSdkContext();
context.client = client;
context.memberListStore.isPresenceEnabled = jest.fn().mockReturnValue(enablePresence);
root = render(
<SDKContext.Provider value={context}>
<MemberList
searchQuery=""
onClose={jest.fn()}
onSearchQueryChanged={jest.fn()}
roomId={memberListRoom.roomId}
ref={gatherWrappedRef}
/>
</SDKContext.Provider>,
);
}
describe.each([false, true])("does order members correctly (presence %s)", (enablePresence) => {
beforeEach(function () {
TestUtils.stubClient();
client = MatrixClientPeg.safeGet();
client.hasLazyLoadMembersEnabled = () => false;
// Make room
memberListRoom = createRoom();
expect(memberListRoom.roomId).toBeTruthy();
// Make users
adminUsers = [];
moderatorUsers = [];
defaultUsers = [];
const usersPerLevel = 2;
for (let i = 0; i < usersPerLevel; i++) {
const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`);
adminUser.membership = "join";
adminUser.powerLevel = 100;
adminUser.user = new User(adminUser.userId);
adminUser.user.currentlyActive = true;
adminUser.user.presence = "online";
adminUser.user.lastPresenceTs = 1000;
adminUser.user.lastActiveAgo = 10;
adminUsers.push(adminUser);
const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`);
moderatorUser.membership = "join";
moderatorUser.powerLevel = 50;
moderatorUser.user = new User(moderatorUser.userId);
moderatorUser.user.currentlyActive = true;
moderatorUser.user.presence = "online";
moderatorUser.user.lastPresenceTs = 1000;
moderatorUser.user.lastActiveAgo = 10;
moderatorUsers.push(moderatorUser);
const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`);
defaultUser.membership = "join";
defaultUser.powerLevel = 0;
defaultUser.user = new User(defaultUser.userId);
defaultUser.user.currentlyActive = true;
defaultUser.user.presence = "online";
defaultUser.user.lastPresenceTs = 1000;
defaultUser.user.lastActiveAgo = 10;
defaultUsers.push(defaultUser);
}
client.getRoom = (roomId) => {
if (roomId === memberListRoom.roomId) return memberListRoom;
else return null;
};
memberListRoom.currentState = {
members: {},
getMember: jest.fn(),
getStateEvents: ((eventType, stateKey) =>
stateKey === undefined ? [] : null) as RoomState["getStateEvents"], // ignore 3pid invites
} as unknown as RoomState;
for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) {
memberListRoom.currentState.members[member.userId] = member;
}
const gatherWrappedRef = (r: MemberList) => {
memberList = r;
};
const context = new TestSdkContext();
context.client = client;
context.memberListStore.isPresenceEnabled = jest.fn().mockReturnValue(enablePresence);
root = render(
<SDKContext.Provider value={context}>
<MemberList
searchQuery=""
onClose={jest.fn()}
onSearchQueryChanged={jest.fn()}
roomId={memberListRoom.roomId}
ref={gatherWrappedRef}
/>
</SDKContext.Provider>,
);
renderMemberList(enablePresence);
});
describe("does order members correctly", () => {
@ -308,4 +312,24 @@ describe("MemberList", () => {
});
});
});
describe("memberlist is rendered correctly", () => {
beforeEach(function () {
renderMemberList(true);
});
it("memberlist is re-rendered on unreachable presence event", async () => {
defaultUsers[0].user?.setPresenceEvent(
new MatrixEvent({
type: "m.presence",
sender: defaultUsers[0].userId,
content: {
presence: "io.element.unreachable",
currently_active: false,
},
}),
);
expect(await screen.findByText(/User's server unreachable/)).toBeInTheDocument();
});
});
});

View file

@ -32,4 +32,17 @@ describe("<PresenceLabel/>", () => {
</DocumentFragment>
`);
});
it("should render 'Unreachable' for presence=unreachable", () => {
const { asFragment } = render(<PresenceLabel presenceState="io.element.unreachable" />);
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div
class="mx_PresenceLabel"
>
User's server unreachable
</div>
</DocumentFragment>
`);
});
});