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; background-color: $header-panel-text-primary-color;
} }
.mx_EntityTile .mx_PresenceLabel { .mx_EntityTile:not(.mx_EntityTile_unreachable) .mx_PresenceLabel {
display: none; display: none;
} }
.mx_EntityTile:not(.mx_EntityTile_noHover):hover .mx_PresenceLabel { .mx_EntityTile:hover .mx_PresenceLabel {
display: block; display: block;
} }
@ -106,7 +106,9 @@ limitations under the License.
} }
.mx_EntityTile_unknown .mx_EntityTile_avatar, .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; 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" /> <BaseAvatar url={require("../../../../res/img/ellipsis.svg").default} name="..." size="36px" />
} }
name={text} name={text}
presenceState="online" showPresence={false}
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} onClick={() => setTruncateAt(totalCount)}
/> />
); );

View file

@ -35,12 +35,13 @@ const PowerLabel: Record<PowerStatus, TranslationKey> = {
[PowerStatus.Moderator]: _td("power_level|mod"), [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> = { const PRESENCE_CLASS: Record<PresenceState, string> = {
offline: "mx_EntityTile_offline", "offline": "mx_EntityTile_offline",
online: "mx_EntityTile_online", "online": "mx_EntityTile_online",
unavailable: "mx_EntityTile_unavailable", "unavailable": "mx_EntityTile_unavailable",
"io.element.unreachable": "mx_EntityTile_unreachable",
}; };
function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string { function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string {
@ -75,7 +76,6 @@ interface IProps {
presenceCurrentlyActive?: boolean; presenceCurrentlyActive?: boolean;
showInviteButton: boolean; showInviteButton: boolean;
onClick(): void; onClick(): void;
suppressOnHover: boolean;
showPresence: boolean; showPresence: boolean;
subtextLabel?: string; subtextLabel?: string;
e2eStatus?: E2EState; e2eStatus?: E2EState;
@ -93,7 +93,6 @@ export default class EntityTile extends React.PureComponent<IProps, IState> {
presenceLastActiveAgo: 0, presenceLastActiveAgo: 0,
presenceLastTs: 0, presenceLastTs: 0,
showInviteButton: false, showInviteButton: false,
suppressOnHover: false,
showPresence: true, 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 { public render(): React.ReactNode {
const mainClassNames: Record<string, boolean> = { const mainClassNames: Record<string, boolean> = {
mx_EntityTile: true, mx_EntityTile: true,
mx_EntityTile_noHover: !!this.props.suppressOnHover,
}; };
if (this.props.className) mainClassNames[this.props.className] = true; 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; mainClassNames[presenceClass] = true;
let nameEl;
const name = this.props.nameJSX || this.props.name; const name = this.props.nameJSX || this.props.name;
const nameAndPresence = (
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_details">
<div className="mx_EntityTile_name">{name}</div> <div className="mx_EntityTile_name">{name}</div>
{presenceLabel} {this.getPresenceLabel()}
</div> </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>;
}
let inviteButton; let inviteButton;
if (this.props.showInviteButton) { if (this.props.showInviteButton) {
@ -198,7 +184,7 @@ export default class EntityTile extends React.PureComponent<IProps, IState> {
{av} {av}
{e2eIcon} {e2eIcon}
</div> </div>
{nameEl} {nameAndPresence}
{powerLabel} {powerLabel}
{inviteButton} {inviteButton}
</AccessibleButton> </AccessibleButton>

View file

@ -81,6 +81,7 @@ export default class MemberList extends React.Component<IProps, IState> {
public static contextType = SDKContext; public static contextType = SDKContext;
public context!: React.ContextType<typeof SDKContext>; public context!: React.ContextType<typeof SDKContext>;
private tiles: Map<string, MemberTile> = new Map();
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) { public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props); 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 // Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile // member tile and re-render it. This is more efficient than every tile
// ever attaching their own listener. // ever attaching their own listener.
const tile = this.refs[user.userId]; const tile = this.tiles.get(user.userId);
if (tile) { if (tile) {
this.updateList(); // reorder the membership list 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" /> <BaseAvatar url={require("../../../../res/img/ellipsis.svg").default} name="..." size="36px" />
} }
name={text} name={text}
presenceState="online" showPresence={false}
suppressOnHover={true}
onClick={onClick} onClick={onClick}
/> />
); );
@ -307,14 +307,24 @@ export default class MemberList extends React.Component<IProps, IState> {
return members.map((m) => { return members.map((m) => {
if (m instanceof RoomMember) { if (m instanceof RoomMember) {
// Is a Matrix invite // 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 { } else {
// Is a 3pid invite // Is a 3pid invite
return ( return (
<EntityTile <EntityTile
key={m.getStateKey()} key={m.getStateKey()}
name={m.getContent().display_name} name={m.getContent().display_name}
suppressOnHover={true} showPresence={false}
onClick={() => this.onPending3pidInviteClick(m)} 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. // the 'active ago' ends up being 0.
if (presence && BUSY_PRESENCE_NAME.matches(presence)) return _t("presence|busy"); 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) { if (!currentlyActive && activeAgo !== undefined && activeAgo > 0) {
const duration = formatDuration(activeAgo); const duration = formatDuration(activeAgo);
if (presence === "online") return _t("presence|online_for", { duration: duration }); if (presence === "online") return _t("presence|online_for", { duration: duration });

View file

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

View file

@ -16,8 +16,8 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { act, render, RenderResult } from "@testing-library/react"; import { act, render, RenderResult, screen } from "@testing-library/react";
import { Room, MatrixClient, RoomState, RoomMember, User } from "matrix-js-sdk/src/matrix"; import { Room, MatrixClient, RoomState, RoomMember, User, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { compare } from "matrix-js-sdk/src/utils"; import { compare } from "matrix-js-sdk/src/utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
@ -137,8 +137,7 @@ describe("MemberList", () => {
} }
} }
describe.each([false, true])("does order members correctly (presence %s)", (enablePresence) => { function renderMemberList(enablePresence: boolean): void {
beforeEach(function () {
TestUtils.stubClient(); TestUtils.stubClient();
client = MatrixClientPeg.safeGet(); client = MatrixClientPeg.safeGet();
client.hasLazyLoadMembersEnabled = () => false; client.hasLazyLoadMembersEnabled = () => false;
@ -156,7 +155,7 @@ describe("MemberList", () => {
const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`); const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`);
adminUser.membership = "join"; adminUser.membership = "join";
adminUser.powerLevel = 100; adminUser.powerLevel = 100;
adminUser.user = new User(adminUser.userId); adminUser.user = User.createUser(adminUser.userId, client);
adminUser.user.currentlyActive = true; adminUser.user.currentlyActive = true;
adminUser.user.presence = "online"; adminUser.user.presence = "online";
adminUser.user.lastPresenceTs = 1000; adminUser.user.lastPresenceTs = 1000;
@ -166,7 +165,7 @@ describe("MemberList", () => {
const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`); const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`);
moderatorUser.membership = "join"; moderatorUser.membership = "join";
moderatorUser.powerLevel = 50; moderatorUser.powerLevel = 50;
moderatorUser.user = new User(moderatorUser.userId); moderatorUser.user = User.createUser(moderatorUser.userId, client);
moderatorUser.user.currentlyActive = true; moderatorUser.user.currentlyActive = true;
moderatorUser.user.presence = "online"; moderatorUser.user.presence = "online";
moderatorUser.user.lastPresenceTs = 1000; moderatorUser.user.lastPresenceTs = 1000;
@ -176,7 +175,7 @@ describe("MemberList", () => {
const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`); const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`);
defaultUser.membership = "join"; defaultUser.membership = "join";
defaultUser.powerLevel = 0; defaultUser.powerLevel = 0;
defaultUser.user = new User(defaultUser.userId); defaultUser.user = User.createUser(defaultUser.userId, client);
defaultUser.user.currentlyActive = true; defaultUser.user.currentlyActive = true;
defaultUser.user.presence = "online"; defaultUser.user.presence = "online";
defaultUser.user.lastPresenceTs = 1000; defaultUser.user.lastPresenceTs = 1000;
@ -215,6 +214,11 @@ describe("MemberList", () => {
/> />
</SDKContext.Provider>, </SDKContext.Provider>,
); );
}
describe.each([false, true])("does order members correctly (presence %s)", (enablePresence) => {
beforeEach(function () {
renderMemberList(enablePresence);
}); });
describe("does order members correctly", () => { 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> </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>
`);
});
}); });