Apply strictNullChecks to src/components/views/settings (#10724)

This commit is contained in:
Kerry 2023-05-05 20:13:50 +12:00 committed by GitHub
parent a4f0b80692
commit 1f4d857283
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 772 additions and 48 deletions

View file

@ -32,7 +32,12 @@ export const Tag: React.FC<IProps> = ({ icon, label, onDeleteClick, disabled = f
{icon?.()}
{label}
{onDeleteClick && (
<AccessibleButton className="mx_Tag_delete" onClick={onDeleteClick} disabled={disabled}>
<AccessibleButton
aria-label="Remove"
className="mx_Tag_delete"
onClick={onDeleteClick}
disabled={disabled}
>
<CancelRounded />
</AccessibleButton>
)}

View file

@ -36,7 +36,7 @@ import { Action } from "../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { doesRoomVersionSupport, PreferredRoomVersions } from "../../../utils/PreferredRoomVersions";
interface IProps {
export interface JoinRuleSettingsProps {
room: Room;
promptUpgrade?: boolean;
closeSettingsFn(): void;
@ -45,7 +45,7 @@ interface IProps {
aliasWarning?: ReactNode;
}
const JoinRuleSettings: React.FC<IProps> = ({
const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
room,
promptUpgrade,
aliasWarning,
@ -287,7 +287,10 @@ const JoinRuleSettings: React.FC<IProps> = ({
fn(_t("Upgrading room"), 0, total);
} else if (!progress.roomSynced) {
fn(_t("Loading new room"), 1, total);
} else if (progress.inviteUsersProgress < progress.inviteUsersTotal) {
} else if (
progress.inviteUsersProgress !== undefined &&
progress.inviteUsersProgress < progress.inviteUsersTotal
) {
fn(
_t("Sending invites... (%(progress)s out of %(count)s)", {
progress: progress.inviteUsersProgress,
@ -296,13 +299,16 @@ const JoinRuleSettings: React.FC<IProps> = ({
2 + progress.inviteUsersProgress,
total,
);
} else if (progress.updateSpacesProgress < progress.updateSpacesTotal) {
} else if (
progress.updateSpacesProgress !== undefined &&
progress.updateSpacesProgress < progress.updateSpacesTotal
) {
fn(
_t("Updating spaces... (%(progress)s out of %(count)s)", {
progress: progress.updateSpacesProgress,
count: progress.updateSpacesTotal,
}),
2 + progress.inviteUsersProgress + progress.updateSpacesProgress,
2 + (progress.inviteUsersProgress ?? 0) + progress.updateSpacesProgress,
total,
);
}

View file

@ -167,7 +167,7 @@ const maximumVectorState = (
if (!definition.syncedRuleIds?.length) {
return undefined;
}
const vectorState = definition.syncedRuleIds.reduce<VectorState | undefined>((maxVectorState, ruleId) => {
const vectorState = definition.syncedRuleIds.reduce<VectorState>((maxVectorState, ruleId) => {
// already set to maximum
if (maxVectorState === VectorState.Loud) {
return maxVectorState;
@ -177,12 +177,15 @@ const maximumVectorState = (
const syncedRuleVectorState = definition.ruleToVectorState(syncedRule);
// if syncedRule is 'louder' than current maximum
// set maximum to louder vectorState
if (OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState)) {
if (
syncedRuleVectorState &&
OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState)
) {
return syncedRuleVectorState;
}
}
return maxVectorState;
}, definition.ruleToVectorState(rule));
}, definition.ruleToVectorState(rule)!);
return vectorState;
};
@ -281,7 +284,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
}
private async refreshRules(): Promise<Partial<IState>> {
const ruleSets = await MatrixClientPeg.get().getPushRules();
const ruleSets = await MatrixClientPeg.get().getPushRules()!;
const categories: Record<string, RuleClass> = {
[RuleId.Master]: RuleClass.Master,
@ -316,7 +319,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
// noinspection JSUnfilteredForInLoop
const kind = k as PushRuleKind;
for (const r of ruleSets.global[kind]) {
for (const r of ruleSets.global[kind]!) {
const rule: IAnnotatedPushRule = Object.assign(r, { kind });
const category = categories[rule.rule_id] ?? RuleClass.Other;
@ -344,7 +347,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
preparedNewState.vectorPushRules[category] = [];
for (const rule of defaultRules[category]) {
const definition: VectorPushRuleDefinition = VectorPushRulesDefinitions[rule.rule_id];
const vectorState = definition.ruleToVectorState(rule);
const vectorState = definition.ruleToVectorState(rule)!;
preparedNewState.vectorPushRules[category]!.push({
ruleId: rule.rule_id,
rule,
@ -441,8 +444,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
} else {
const pusher = this.state.pushers?.find((p) => p.kind === "email" && p.pushkey === email);
if (pusher) {
pusher.kind = null; // flag for delete
await MatrixClientPeg.get().setPusher(pusher);
await MatrixClientPeg.get().removePusher(pusher.pushkey, pusher.app_id);
}
}
@ -539,17 +541,20 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
}
};
private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]): Promise<void> {
private async setKeywords(
unsafeKeywords: (string | undefined)[],
originalRules: IAnnotatedPushRule[],
): Promise<void> {
try {
// De-duplicate and remove empties
keywords = filterBoolean(Array.from(new Set(keywords)));
const oldKeywords = filterBoolean(Array.from(new Set(originalRules.map((r) => r.pattern))));
const keywords = filterBoolean<string>(Array.from(new Set(unsafeKeywords)));
const oldKeywords = filterBoolean<string>(Array.from(new Set(originalRules.map((r) => r.pattern))));
// Note: Technically because of the UI interaction (at the time of writing), the diff
// will only ever be +/-1 so we don't really have to worry about efficiently handling
// tons of keyword changes.
const diff = arrayDiff(oldKeywords, keywords);
const diff = arrayDiff<string>(oldKeywords, keywords);
for (const word of diff.removed) {
for (const rule of originalRules.filter((r) => r.pattern === word)) {
@ -557,16 +562,16 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
}
}
let ruleVectorState = this.state.vectorKeywordRuleInfo?.vectorState;
let ruleVectorState = this.state.vectorKeywordRuleInfo!.vectorState;
if (ruleVectorState === VectorState.Off) {
// When the current global keywords rule is OFF, we need to look at
// the flavor of existing rules to apply the same actions
// when creating the new rule.
if (originalRules.length) {
ruleVectorState = PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]) ?? undefined;
} else {
ruleVectorState = VectorState.On; // default
}
const existingRuleVectorState = originalRules.length
? PushRuleVectorState.contentRuleVectorStateKind(originalRules[0])
: undefined;
// set to same state as existing rule, or default to On
ruleVectorState = existingRuleVectorState ?? VectorState.On; //default
}
const kind = PushRuleKind.ContentSpecific;
for (const word of diff.added) {
@ -588,6 +593,10 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
}
private onKeywordAdd = (keyword: string): void => {
// should not encounter this
if (!this.state.vectorKeywordRuleInfo) {
throw new Error("Notification data is incomplete.");
}
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
// We add the keyword immediately as a sort of local echo effect
@ -606,7 +615,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
},
async (): Promise<void> => {
await this.setKeywords(
this.state.vectorKeywordRuleInfo.rules.map((r) => r.pattern),
this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern),
originalRules,
);
},
@ -614,6 +623,10 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
};
private onKeywordRemove = (keyword: string): void => {
// should not encounter this
if (!this.state.vectorKeywordRuleInfo) {
throw new Error("Notification data is incomplete.");
}
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
// We remove the keyword immediately as a sort of local echo effect
@ -627,7 +640,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
},
async (): Promise<void> => {
await this.setKeywords(
this.state.vectorKeywordRuleInfo.rules.map((r) => r.pattern),
this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern),
originalRules,
);
},
@ -749,9 +762,10 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
let keywordComposer: JSX.Element | undefined;
if (category === RuleClass.VectorMentions) {
const tags = filterBoolean<string>(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []);
keywordComposer = (
<TagComposer
tags={this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern)}
tags={tags}
onAdd={this.onKeywordAdd}
onRemove={this.onKeywordRemove}
disabled={this.state.phase === Phase.Persisting}

View file

@ -65,7 +65,9 @@ export default class ProfileSettings extends React.Component<{}, IState> {
private removeAvatar = (): void => {
// clear file upload field so same file can be selected
if (this.avatarUpload.current) {
this.avatarUpload.current.value = "";
}
this.setState({
avatarUrl: undefined,
avatarFile: null,

View file

@ -99,12 +99,12 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
private async checkKeyBackupStatus(): Promise<void> {
this.getUpdatedDiagnostics();
try {
const { backupInfo, trustInfo } = await MatrixClientPeg.get().checkKeyBackup();
const keyBackupResult = await MatrixClientPeg.get().checkKeyBackup();
this.setState({
loading: false,
error: null,
backupInfo,
backupSigStatus: trustInfo,
backupInfo: keyBackupResult?.backupInfo ?? null,
backupSigStatus: keyBackupResult?.trustInfo ?? null,
});
} catch (e) {
logger.log("Unable to fetch check backup status", e);
@ -123,7 +123,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
this.getUpdatedDiagnostics();
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo!);
const backupSigStatus = backupInfo ? await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) : null;
if (this.unmounted) return;
this.setState({
loading: false,
@ -192,7 +192,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
if (!proceed) return;
this.setState({ loading: true });
MatrixClientPeg.get()
.deleteKeyBackupVersion(this.state.backupInfo.version)
.deleteKeyBackupVersion(this.state.backupInfo!.version!)
.then(() => {
this.loadBackupStatus();
});
@ -285,7 +285,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
);
}
let backupSigStatuses: React.ReactNode = backupSigStatus?.sigs.map((sig, i) => {
let backupSigStatuses: React.ReactNode | undefined = backupSigStatus?.sigs?.map((sig, i) => {
const deviceName = sig.device ? sig.device.getDisplayName() || sig.device.deviceId : null;
const validity = (sub: string): JSX.Element => (
<span className={sig.valid ? "mx_SecureBackupPanel_sigValid" : "mx_SecureBackupPanel_sigInvalid"}>
@ -354,7 +354,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
{},
{ validity, verify, device },
);
} else if (sig.valid && !sig.deviceTrust.isVerified()) {
} else if (sig.valid && !sig.deviceTrust?.isVerified()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from " +
"<verify>unverified</verify> session <device></device>",
@ -368,7 +368,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
{},
{ validity, verify, device },
);
} else if (!sig.valid && !sig.deviceTrust.isVerified()) {
} else if (!sig.valid && !sig.deviceTrust?.isVerified()) {
sigStatus = _t(
"Backup has an <validity>invalid</validity> signature from " +
"<verify>unverified</verify> session <device></device>",

View file

@ -83,7 +83,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
private setDevice = (deviceId: string, kind: MediaDeviceKindEnum): void => {
MediaDeviceHandler.instance.setDevice(deviceId, kind);
this.setState<null>({ [kind]: deviceId });
this.setState<any>({ [kind]: deviceId });
};
private changeWebRtcMethod = (p2p: boolean): void => {

View file

@ -0,0 +1,250 @@
/*
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.
*/
import React from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import {
EventType,
GuestAccess,
HistoryVisibility,
JoinRule,
MatrixEvent,
Room,
ClientEvent,
RoomMember,
} from "matrix-js-sdk/src/matrix";
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import {
clearAllModals,
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsUser,
} from "../../../test-utils";
import { filterBoolean } from "../../../../src/utils/arrays";
import JoinRuleSettings, { JoinRuleSettingsProps } from "../../../../src/components/views/settings/JoinRuleSettings";
import { PreferredRoomVersions } from "../../../../src/utils/PreferredRoomVersions";
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
describe("<JoinRuleSettings />", () => {
const userId = "@alice:server.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getRoom: jest.fn(),
getLocalAliases: jest.fn().mockReturnValue([]),
sendStateEvent: jest.fn(),
upgradeRoom: jest.fn(),
getProfileInfo: jest.fn(),
invite: jest.fn().mockResolvedValue(undefined),
isRoomEncrypted: jest.fn().mockReturnValue(false),
});
const roomId = "!room:server.org";
const newRoomId = "!roomUpgraded:server.org";
const defaultProps = {
room: new Room(roomId, client, userId),
closeSettingsFn: jest.fn(),
onError: jest.fn(),
};
const getComponent = (props: Partial<JoinRuleSettingsProps> = {}) =>
render(<JoinRuleSettings {...defaultProps} {...props} />);
const setRoomStateEvents = (
room: Room,
version = "9",
joinRule?: JoinRule,
guestAccess?: GuestAccess,
history?: HistoryVisibility,
): void => {
const events = filterBoolean<MatrixEvent>([
new MatrixEvent({
type: EventType.RoomCreate,
content: { version },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
guestAccess &&
new MatrixEvent({
type: EventType.RoomGuestAccess,
content: { guest_access: guestAccess },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
history &&
new MatrixEvent({
type: EventType.RoomHistoryVisibility,
content: { history_visibility: history },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
joinRule &&
new MatrixEvent({
type: EventType.RoomJoinRules,
content: { join_rule: joinRule },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
]);
room.currentState.setStateEvents(events);
};
beforeEach(() => {
client.sendStateEvent.mockReset().mockResolvedValue({ event_id: "test" });
client.isRoomEncrypted.mockReturnValue(false);
client.upgradeRoom.mockResolvedValue({ replacement_room: newRoomId });
client.getRoom.mockReturnValue(null);
});
describe("Restricted rooms", () => {
afterEach(async () => {
await clearAllModals();
});
describe("When room does not support restricted rooms", () => {
it("should not show restricted room join rule when upgrade not enabled", () => {
// room that doesnt support restricted rooms
const v8Room = new Room(roomId, client, userId);
setRoomStateEvents(v8Room, "8");
getComponent({ room: v8Room, promptUpgrade: false });
expect(screen.queryByText("Space members")).not.toBeInTheDocument();
});
it("should show restricted room join rule when upgrade is enabled", () => {
// room that doesnt support restricted rooms
const v8Room = new Room(roomId, client, userId);
setRoomStateEvents(v8Room, "8");
getComponent({ room: v8Room, promptUpgrade: true });
expect(screen.getByText("Space members")).toBeInTheDocument();
expect(screen.getByText("Upgrade required")).toBeInTheDocument();
});
it("upgrades room when changing join rule to restricted", async () => {
const deferredInvites: IDeferred<any>[] = [];
// room that doesnt support restricted rooms
const v8Room = new Room(roomId, client, userId);
const parentSpace = new Room("!parentSpace:server.org", client, userId);
jest.spyOn(SpaceStore.instance, "getKnownParents").mockReturnValue(new Set([parentSpace.roomId]));
setRoomStateEvents(v8Room, "8");
const memberAlice = new RoomMember(roomId, "@alice:server.org");
const memberBob = new RoomMember(roomId, "@bob:server.org");
const memberCharlie = new RoomMember(roomId, "@charlie:server.org");
jest.spyOn(v8Room, "getMembersWithMembership").mockImplementation((membership) =>
membership === "join" ? [memberAlice, memberBob] : [memberCharlie],
);
const upgradedRoom = new Room(newRoomId, client, userId);
setRoomStateEvents(upgradedRoom);
client.getRoom.mockImplementation((id) => {
if (roomId === id) return v8Room;
if (parentSpace.roomId === id) return parentSpace;
return null;
});
// resolve invites by hand
// flushPromises is too blunt to test reliably
client.invite.mockImplementation(() => {
const p = defer<{}>();
deferredInvites.push(p);
return p.promise;
});
getComponent({ room: v8Room, promptUpgrade: true });
fireEvent.click(screen.getByText("Space members"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Upgrade"));
expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, PreferredRoomVersions.RestrictedRooms);
expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument();
await flushPromises();
expect(within(dialog).getByText("Loading new room")).toBeInTheDocument();
// "create" our new room, have it come thru sync
client.getRoom.mockImplementation((id) => {
if (roomId === id) return v8Room;
if (newRoomId === id) return upgradedRoom;
if (parentSpace.roomId === id) return parentSpace;
return null;
});
client.emit(ClientEvent.Room, upgradedRoom);
// invite users
expect(await screen.findByText("Sending invites... (0 out of 2)")).toBeInTheDocument();
deferredInvites.pop()!.resolve({});
expect(await screen.findByText("Sending invites... (1 out of 2)")).toBeInTheDocument();
deferredInvites.pop()!.resolve({});
// update spaces
expect(await screen.findByText("Updating space...")).toBeInTheDocument();
await flushPromises();
// done, modal closed
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("upgrades room with no parent spaces or members when changing join rule to restricted", async () => {
// room that doesnt support restricted rooms
const v8Room = new Room(roomId, client, userId);
setRoomStateEvents(v8Room, "8");
const upgradedRoom = new Room(newRoomId, client, userId);
setRoomStateEvents(upgradedRoom);
getComponent({ room: v8Room, promptUpgrade: true });
fireEvent.click(screen.getByText("Space members"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Upgrade"));
expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, PreferredRoomVersions.RestrictedRooms);
expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument();
await flushPromises();
expect(within(dialog).getByText("Loading new room")).toBeInTheDocument();
// "create" our new room, have it come thru sync
client.getRoom.mockImplementation((id) => {
if (roomId === id) return v8Room;
if (newRoomId === id) return upgradedRoom;
return null;
});
client.emit(ClientEvent.Room, upgradedRoom);
await flushPromises();
await flushPromises();
// done, modal closed
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
});
});

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022, 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.
@ -26,16 +26,18 @@ import {
TweakName,
ConditionKind,
IPushRuleCondition,
PushRuleKind,
} from "matrix-js-sdk/src/matrix";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
import { act, fireEvent, getByTestId, render, screen, waitFor, within } from "@testing-library/react";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import Notifications from "../../../../src/components/views/settings/Notifications";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { StandardActions } from "../../../../src/notifications/StandardActions";
import { getMockClientWithEventEmitter, mkMessage, mockClientMethodsUser } from "../../../test-utils";
import { clearAllModals, getMockClientWithEventEmitter, mkMessage, mockClientMethodsUser } from "../../../test-utils";
// don't pollute test output with error logs from mock rejections
jest.mock("matrix-js-sdk/src/logger");
@ -257,6 +259,7 @@ describe("<Notifications />", () => {
getPushers: jest.fn(),
getThreePids: jest.fn(),
setPusher: jest.fn(),
removePusher: jest.fn(),
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getRooms: jest.fn().mockReturnValue([]),
@ -274,10 +277,12 @@ describe("<Notifications />", () => {
sendReadReceipt: jest.fn(),
supportsThreads: jest.fn().mockReturnValue(true),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
addPushRule: jest.fn().mockResolvedValue({}),
deletePushRule: jest.fn().mockResolvedValue({}),
});
mockClient.getPushRules.mockResolvedValue(pushRules);
beforeEach(() => {
beforeEach(async () => {
let i = 0;
mocked(randomString).mockImplementation(() => {
return "testid_" + i++;
@ -286,9 +291,17 @@ describe("<Notifications />", () => {
mockClient.getPushRules.mockClear().mockResolvedValue(pushRules);
mockClient.getPushers.mockClear().mockResolvedValue({ pushers: [] });
mockClient.getThreePids.mockClear().mockResolvedValue({ threepids: [] });
mockClient.setPusher.mockClear().mockResolvedValue({});
mockClient.setPusher.mockReset().mockResolvedValue({});
mockClient.removePusher.mockClear().mockResolvedValue({});
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
mockClient.pushRules = pushRules;
mockClient.getPushRules.mockClear().mockResolvedValue(pushRules);
mockClient.addPushRule.mockClear();
mockClient.deletePushRule.mockClear();
userEvent.setup();
await clearAllModals();
});
it("renders spinner while loading", async () => {
@ -392,21 +405,30 @@ describe("<Notifications />", () => {
// force render
await flushPromises();
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText("An error occurred whilst saving your notification preferences."),
).toBeInTheDocument();
// dismiss the dialog
fireEvent.click(within(dialog).getByText("OK"));
expect(screen.getByTestId("error-message")).toBeInTheDocument();
});
it("enables email notification when toggling off", async () => {
const testPusher = { kind: "email", pushkey: "tester@test.com" } as unknown as IPusher;
const testPusher = {
kind: "email",
pushkey: "tester@test.com",
app_id: "testtest",
} as unknown as IPusher;
mockClient.getPushers.mockResolvedValue({ pushers: [testPusher] });
await getComponentAndWait();
const emailToggle = screen.getByTestId("notif-email-switch").querySelector('div[role="switch"]')!;
fireEvent.click(emailToggle);
expect(mockClient.setPusher).toHaveBeenCalledWith({
...testPusher,
kind: null,
});
expect(mockClient.removePusher).toHaveBeenCalledWith(testPusher.pushkey, testPusher.app_id);
});
});
@ -809,6 +831,66 @@ describe("<Notifications />", () => {
),
).toBeInTheDocument();
});
it("adds a new keyword", async () => {
await getComponentAndWait();
await userEvent.type(screen.getByLabelText("Keyword"), "jest");
expect(screen.getByLabelText("Keyword")).toHaveValue("jest");
fireEvent.click(screen.getByText("Add"));
expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", {
actions: [PushRuleActionName.Notify, { set_tweak: "highlight", value: false }],
pattern: "jest",
});
});
it("adds a new keyword with same actions as existing rules when keywords rule is off", async () => {
const offContentRule = {
...bananaRule,
enabled: false,
actions: [PushRuleActionName.Notify],
};
const pushRulesWithContentOff = {
global: {
...pushRules.global,
content: [offContentRule],
},
};
mockClient.pushRules = pushRulesWithContentOff;
mockClient.getPushRules.mockClear().mockResolvedValue(pushRulesWithContentOff);
await getComponentAndWait();
const keywords = screen.getByTestId("vector_mentions_keywords");
expect(within(keywords).getByLabelText("Off")).toBeChecked();
await userEvent.type(screen.getByLabelText("Keyword"), "jest");
expect(screen.getByLabelText("Keyword")).toHaveValue("jest");
fireEvent.click(screen.getByText("Add"));
expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }],
pattern: "jest",
});
});
it("removes keyword", async () => {
await getComponentAndWait();
await userEvent.type(screen.getByLabelText("Keyword"), "jest");
const keyword = screen.getByText("banana");
fireEvent.click(within(keyword.parentElement!).getByLabelText("Remove"));
expect(mockClient.deletePushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "banana");
await flushPromises();
});
});
describe("clear all notifications", () => {

View file

@ -0,0 +1,187 @@
/*
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.
*/
import React from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { mocked } from "jest-mock";
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils";
import SecureBackupPanel from "../../../../src/components/views/settings/SecureBackupPanel";
import { accessSecretStorage } from "../../../../src/SecurityManager";
jest.mock("../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn(),
}));
describe("<SecureBackupPanel />", () => {
const userId = "@alice:server.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
checkKeyBackup: jest.fn(),
isKeyBackupKeyStored: jest.fn(),
isSecretStorageReady: jest.fn(),
getKeyBackupEnabled: jest.fn(),
getKeyBackupVersion: jest.fn().mockReturnValue("1"),
isKeyBackupTrusted: jest.fn().mockResolvedValue(true),
getClientWellKnown: jest.fn(),
deleteKeyBackupVersion: jest.fn(),
});
// @ts-ignore allow it
client.crypto = {
secretStorage: { hasKey: jest.fn() },
getSessionBackupPrivateKey: jest.fn(),
} as unknown as Crypto;
const getComponent = () => render(<SecureBackupPanel />);
beforeEach(() => {
client.checkKeyBackup.mockResolvedValue({
backupInfo: {
version: "1",
algorithm: "test",
auth_data: {
public_key: "1234",
},
},
trustInfo: {
usable: false,
sigs: [],
},
});
mocked(client.crypto!.secretStorage.hasKey).mockClear().mockResolvedValue(false);
client.deleteKeyBackupVersion.mockClear().mockResolvedValue();
client.getKeyBackupVersion.mockClear();
client.isKeyBackupTrusted.mockClear();
mocked(accessSecretStorage).mockClear().mockResolvedValue();
});
it("displays a loader while checking keybackup", async () => {
getComponent();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
await flushPromises();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
it("handles null backup info", async () => {
// checkKeyBackup can fail and return null for various reasons
client.checkKeyBackup.mockResolvedValue(null);
getComponent();
// flush checkKeyBackup promise
await flushPromises();
// no backup info
expect(screen.getByText("Back up your keys before signing out to avoid losing them.")).toBeInTheDocument();
});
it("suggests connecting session to key backup when backup exists", async () => {
const { container } = getComponent();
// flush checkKeyBackup promise
await flushPromises();
expect(container).toMatchSnapshot();
});
it("displays when session is connected to key backup", async () => {
client.getKeyBackupEnabled.mockReturnValue(true);
getComponent();
// flush checkKeyBackup promise
await flushPromises();
expect(screen.getByText("✅ This session is backing up your keys.")).toBeInTheDocument();
});
it("asks for confirmation before deleting a backup", async () => {
getComponent();
// flush checkKeyBackup promise
await flushPromises();
fireEvent.click(screen.getByText("Delete Backup"));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByText("Cancel"));
expect(client.deleteKeyBackupVersion).not.toHaveBeenCalled();
});
it("deletes backup after confirmation", async () => {
client.checkKeyBackup
.mockResolvedValueOnce({
backupInfo: {
version: "1",
algorithm: "test",
auth_data: {
public_key: "1234",
},
},
trustInfo: {
usable: false,
sigs: [],
},
})
.mockResolvedValue(null);
getComponent();
// flush checkKeyBackup promise
await flushPromises();
fireEvent.click(screen.getByText("Delete Backup"));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByTestId("dialog-primary-button"));
expect(client.deleteKeyBackupVersion).toHaveBeenCalledWith("1");
// delete request
await flushPromises();
// refresh backup info
await flushPromises();
});
it("resets secret storage", async () => {
mocked(client.crypto!.secretStorage.hasKey).mockClear().mockResolvedValue(true);
getComponent();
// flush checkKeyBackup promise
await flushPromises();
client.getKeyBackupVersion.mockClear();
client.isKeyBackupTrusted.mockClear();
fireEvent.click(screen.getByText("Reset"));
// enter loading state
expect(accessSecretStorage).toHaveBeenCalled();
await flushPromises();
// backup status refreshed
expect(client.getKeyBackupVersion).toHaveBeenCalled();
expect(client.isKeyBackupTrusted).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,116 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SecureBackupPanel /> suggests connecting session to key backup when backup exists 1`] = `
<div>
<div>
<p>
Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.
</p>
<p>
<span>
This session is
<b>
not backing up your keys
</b>
, but you do have an existing backup you can restore from and add to going forward.
</span>
</p>
<p>
Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.
</p>
<details>
<summary>
Advanced
</summary>
<table
class="mx_SecureBackupPanel_statusList"
>
<tr>
<th
scope="row"
>
Backup key stored:
</th>
<td>
not stored
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
not ready
</td>
</tr>
<tr>
<th
scope="row"
>
Backup version:
</th>
<td>
1
</td>
</tr>
<tr>
<th
scope="row"
>
Algorithm:
</th>
<td>
test
</td>
</tr>
</table>
<div>
Backup is not signed by any of your sessions
</div>
<div />
</details>
<div
class="mx_SecureBackupPanel_buttonRow"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Connect this session to Key Backup
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
role="button"
tabindex="0"
>
Delete Backup
</div>
</div>
</div>
</div>
`;

View file

@ -16,10 +16,11 @@ limitations under the License.
import React from "react";
import { mocked } from "jest-mock";
import { render } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import VoiceUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/VoiceUserSettingsTab";
import MediaDeviceHandler from "../../../../../../src/MediaDeviceHandler";
import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../../src/MediaDeviceHandler";
import { flushPromises } from "../../../../../test-utils";
jest.mock("../../../../../../src/MediaDeviceHandler");
const MediaDeviceHandlerMock = mocked(MediaDeviceHandler);
@ -27,8 +28,69 @@ const MediaDeviceHandlerMock = mocked(MediaDeviceHandler);
describe("<VoiceUserSettingsTab />", () => {
const getComponent = (): React.ReactElement => <VoiceUserSettingsTab />;
const audioIn1 = {
deviceId: "1",
groupId: "g1",
kind: MediaDeviceKindEnum.AudioInput,
label: "Audio input test 1",
};
const videoIn1 = {
deviceId: "2",
groupId: "g1",
kind: MediaDeviceKindEnum.VideoInput,
label: "Video input test 1",
};
const videoIn2 = {
deviceId: "3",
groupId: "g1",
kind: MediaDeviceKindEnum.VideoInput,
label: "Video input test 2",
};
const defaultMediaDevices = {
[MediaDeviceKindEnum.AudioOutput]: [],
[MediaDeviceKindEnum.AudioInput]: [audioIn1],
[MediaDeviceKindEnum.VideoInput]: [videoIn1, videoIn2],
} as unknown as IMediaDevices;
beforeEach(() => {
jest.clearAllMocks();
MediaDeviceHandlerMock.hasAnyLabeledDevices.mockResolvedValue(true);
MediaDeviceHandlerMock.getDevices.mockResolvedValue(defaultMediaDevices);
// @ts-ignore bad mocking
MediaDeviceHandlerMock.instance = { setDevice: jest.fn() };
});
describe("devices", () => {
it("renders dropdowns for input devices", async () => {
render(getComponent());
await flushPromises();
expect(screen.getByLabelText("Microphone")).toHaveDisplayValue(audioIn1.label);
expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn1.label);
});
it("updates device", async () => {
render(getComponent());
await flushPromises();
fireEvent.change(screen.getByLabelText("Camera"), { target: { value: videoIn2.deviceId } });
expect(MediaDeviceHandlerMock.instance.setDevice).toHaveBeenCalledWith(
videoIn2.deviceId,
MediaDeviceKindEnum.VideoInput,
);
expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn2.label);
});
it("does not render dropdown when no devices exist for type", async () => {
render(getComponent());
await flushPromises();
expect(screen.getByText("No Audio Outputs detected")).toBeInTheDocument();
expect(screen.queryByLabelText("Audio Output")).not.toBeInTheDocument();
});
});
it("renders audio processing settings", () => {