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:
parent
6849afd9fc
commit
90419bdffd
9 changed files with 234 additions and 133 deletions
64
cypress/e2e/presence/presence.spec.ts
Normal file
64
cypress/e2e/presence/presence.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue