Merge branch 'develop' into bump-matrix-wysiwyg-to-0.8.0

This commit is contained in:
Andy Balaam 2022-12-01 10:03:49 +00:00 committed by GitHub
commit b51b4a994d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1862 additions and 2645 deletions

View file

@ -43,7 +43,7 @@ jobs:
- name: Get commit details - name: Get commit details
id: commit id: commit
if: github.event.workflow_run.event == 'pull_request' if: github.event.workflow_run.event == 'pull_request'
uses: actions/github-script@v5 uses: actions/github-script@v6
with: with:
script: | script: |
const response = await github.rest.git.getCommit({ const response = await github.rest.git.getCommit({
@ -82,7 +82,7 @@ jobs:
# Run 4 instances in Parallel # Run 4 instances in Parallel
runner: [1, 2, 3, 4] runner: [1, 2, 3, 4]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
# XXX: We're checking out untrusted code in a secure context # XXX: We're checking out untrusted code in a secure context
# We need to be careful to not trust anything this code outputs/may do # We need to be careful to not trust anything this code outputs/may do
@ -147,7 +147,7 @@ jobs:
- name: Upload Artifact - name: Upload Artifact
if: failure() if: failure()
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: cypress-results name: cypress-results
path: | path: |

View file

@ -17,7 +17,7 @@ jobs:
name: "Build Element-Web" name: "Build Element-Web"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
@ -46,7 +46,7 @@ jobs:
working-directory: ./element-web working-directory: ./element-web
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: previewbuild name: previewbuild
path: element-web/webapp path: element-web/webapp

View file

@ -12,7 +12,7 @@ jobs:
- name: "Get modified files" - name: "Get modified files"
id: changed_files id: changed_files
if: github.event_name == 'pull_request' && github.event.pull_request.user.login != 'RiotTranslateBot' if: github.event_name == 'pull_request' && github.event.pull_request.user.login != 'RiotTranslateBot'
uses: tj-actions/changed-files@v19 uses: tj-actions/changed-files@v34
with: with:
files: | files: |
src/i18n/strings/* src/i18n/strings/*

View file

@ -12,7 +12,7 @@ jobs:
if: github.repository == 'matrix-org/matrix-react-sdk' if: github.repository == 'matrix-org/matrix-react-sdk'
steps: steps:
- name: Notify element-web repo that a new SDK build is on develop - name: Notify element-web repo that a new SDK build is on develop
uses: peter-evans/repository-dispatch@v1 uses: peter-evans/repository-dispatch@v2
with: with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: vector-im/element-web repository: vector-im/element-web

View file

@ -14,7 +14,7 @@ jobs:
name: "Typescript Syntax Check" name: "Typescript Syntax Check"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
@ -89,7 +89,7 @@ jobs:
name: "Rethemendex Check" name: "Rethemendex Check"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- run: ./res/css/rethemendex.sh - run: ./res/css/rethemendex.sh
@ -99,7 +99,7 @@ jobs:
name: "ESLint" name: "ESLint"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
@ -116,7 +116,7 @@ jobs:
name: "Style Lint" name: "Style Lint"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
@ -133,7 +133,7 @@ jobs:
name: "Analyse Dead Code" name: "Analyse Dead Code"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Yarn cache - name: Yarn cache
uses: actions/setup-node@v3 uses: actions/setup-node@v3
@ -38,7 +38,7 @@ jobs:
run: "yarn coverage --ci --reporters github-actions --max-workers ${{ steps.cpu-cores.outputs.count }}" run: "yarn coverage --ci --reporters github-actions --max-workers ${{ steps.cpu-cores.outputs.count }}"
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: coverage name: coverage
path: | path: |
@ -49,7 +49,7 @@ jobs:
name: Element Web Integration Tests name: Element Web Integration Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:

View file

@ -72,9 +72,9 @@
"counterpart": "^0.18.6", "counterpart": "^0.18.6",
"diff-dom": "^4.2.2", "diff-dom": "^4.2.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"emojibase": "6.0.2", "emojibase": "6.1.0",
"emojibase-data": "7.0.0", "emojibase-data": "7.0.1",
"emojibase-regex": "6.0.0", "emojibase-regex": "6.0.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"filesize": "6.1.0", "filesize": "6.1.0",
@ -149,7 +149,7 @@
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
"@types/commonmark": "^0.27.4", "@types/commonmark": "^0.27.4",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/css-font-loading-module": "^0.0.6", "@types/css-font-loading-module": "^0.0.7",
"@types/diff-match-patch": "^1.0.32", "@types/diff-match-patch": "^1.0.32",
"@types/enzyme": "^3.10.9", "@types/enzyme": "^3.10.9",
"@types/escape-html": "^1.0.1", "@types/escape-html": "^1.0.1",
@ -161,7 +161,7 @@
"@types/lodash": "^4.14.168", "@types/lodash": "^4.14.168",
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",
"@types/node": "^16", "@types/node": "^16",
"@types/pako": "^1.0.1", "@types/pako": "^2.0.0",
"@types/parse5": "^6.0.0", "@types/parse5": "^6.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "17.0.49", "@types/react": "17.0.49",
@ -175,8 +175,8 @@
"@typescript-eslint/parser": "^5.6.0", "@typescript-eslint/parser": "^5.6.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
"allchange": "^1.1.0", "allchange": "^1.1.0",
"axe-core": "^4.4.3", "axe-core": "4.4.3",
"babel-jest": "^26.6.3", "babel-jest": "^29.0.0",
"blob-polyfill": "^6.0.20211015", "blob-polyfill": "^6.0.20211015",
"chokidar": "^3.5.1", "chokidar": "^3.5.1",
"cypress": "^10.3.0", "cypress": "^10.3.0",
@ -194,8 +194,8 @@
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-unicorn": "^44.0.2", "eslint-plugin-unicorn": "^44.0.2",
"fetch-mock-jest": "^1.5.1", "fetch-mock-jest": "^1.5.1",
"fs-extra": "^10.0.1", "fs-extra": "^11.0.0",
"glob": "^7.1.6", "glob": "^8.0.0",
"jest": "^29.2.2", "jest": "^29.2.2",
"jest-canvas-mock": "^2.3.0", "jest-canvas-mock": "^2.3.0",
"jest-environment-jsdom": "^29.2.2", "jest-environment-jsdom": "^29.2.2",

View file

@ -114,6 +114,10 @@ limitations under the License.
} }
} }
} }
&:last-child {
margin-bottom: 0;
}
} }
.mx_BetaCard_betaPill { .mx_BetaCard_betaPill {

View file

@ -60,4 +60,8 @@ limitations under the License.
font-family: $monospace-font-family !important; font-family: $monospace-font-family !important;
background-color: $rte-code-bg-color; background-color: $rte-code-bg-color;
} }
.mx_SettingsTab_microcopy_warning::before {
content: "⚠️ ";
}
} }

View file

@ -71,13 +71,52 @@ export const PROTOCOL_SIP_VIRTUAL = 'im.vector.protocol.sip_virtual';
const CHECK_PROTOCOLS_ATTEMPTS = 3; const CHECK_PROTOCOLS_ATTEMPTS = 3;
enum AudioID { type MediaEventType = keyof HTMLMediaElementEventMap;
const MEDIA_ERROR_EVENT_TYPES: MediaEventType[] = [
'error',
// The media has become empty; for example, this event is sent if the media has
// already been loaded (or partially loaded), and the HTMLMediaElement.load method
// is called to reload it.
'emptied',
// The user agent is trying to fetch media data, but data is unexpectedly not
// forthcoming.
'stalled',
// Media data loading has been suspended.
'suspend',
// Playback has stopped because of a temporary lack of data
'waiting',
];
const MEDIA_DEBUG_EVENT_TYPES: MediaEventType[] = [
'play',
'pause',
'playing',
'ended',
'loadeddata',
'loadedmetadata',
'canplay',
'canplaythrough',
'volumechange',
];
const MEDIA_EVENT_TYPES = [
...MEDIA_ERROR_EVENT_TYPES,
...MEDIA_DEBUG_EVENT_TYPES,
];
export enum AudioID {
Ring = 'ringAudio', Ring = 'ringAudio',
Ringback = 'ringbackAudio', Ringback = 'ringbackAudio',
CallEnd = 'callendAudio', CallEnd = 'callendAudio',
Busy = 'busyAudio', Busy = 'busyAudio',
} }
/* istanbul ignore next */
const debuglog = (...args: any[]): void => {
if (SettingsStore.getValue("debug_legacy_call_handler")) {
logger.log.call(console, "LegacyCallHandler debuglog:", ...args);
}
};
interface ThirdpartyLookupResponseFields { interface ThirdpartyLookupResponseFields {
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -119,6 +158,7 @@ export default class LegacyCallHandler extends EventEmitter {
// call with a different party to this one. // call with a different party to this one.
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee) private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
private audioPromises = new Map<AudioID, Promise<void>>(); private audioPromises = new Map<AudioID, Promise<void>>();
private audioElementsWithListeners = new Map<HTMLMediaElement, boolean>();
private supportsPstnProtocol = null; private supportsPstnProtocol = null;
private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
@ -176,6 +216,16 @@ export default class LegacyCallHandler extends EventEmitter {
} }
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS); this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
// Add event listeners for the <audio> elements
Object.values(AudioID).forEach((audioId) => {
const audioElement = document.getElementById(audioId) as HTMLMediaElement;
if (audioElement) {
this.addEventListenersForAudioElement(audioElement);
} else {
logger.warn(`LegacyCallHandler: missing <audio id="${audioId}"> from page`);
}
});
} }
public stop(): void { public stop(): void {
@ -183,6 +233,39 @@ export default class LegacyCallHandler extends EventEmitter {
if (cli) { if (cli) {
cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming); cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming);
} }
// Remove event listeners for the <audio> elements
Array.from(this.audioElementsWithListeners.keys()).forEach((audioElement) => {
this.removeEventListenersForAudioElement(audioElement);
});
}
private addEventListenersForAudioElement(audioElement: HTMLMediaElement): void {
// Only need to setup the listeners once
if (!this.audioElementsWithListeners.get(audioElement)) {
MEDIA_EVENT_TYPES.forEach((errorEventType) => {
audioElement.addEventListener(errorEventType, this);
this.audioElementsWithListeners.set(audioElement, true);
});
}
}
private removeEventListenersForAudioElement(audioElement: HTMLMediaElement): void {
MEDIA_EVENT_TYPES.forEach((errorEventType) => {
audioElement.removeEventListener(errorEventType, this);
});
}
/* istanbul ignore next (remove if we start using this function for things other than debug logging) */
public handleEvent(e: Event): void {
const target = e.target as HTMLElement;
const audioId = target?.id;
if (MEDIA_ERROR_EVENT_TYPES.includes(e.type as MediaEventType)) {
logger.error(`LegacyCallHandler: encountered "${e.type}" event with <audio id="${audioId}">`, e);
} else if (MEDIA_EVENT_TYPES.includes(e.type as MediaEventType)) {
debuglog(`encountered "${e.type}" event with <audio id="${audioId}">`, e);
}
} }
public isForcedSilent(): boolean { public isForcedSilent(): boolean {
@ -402,11 +485,21 @@ export default class LegacyCallHandler extends EventEmitter {
// which listens? // which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement; const audio = document.getElementById(audioId) as HTMLMediaElement;
if (audio) { if (audio) {
this.addEventListenersForAudioElement(audio);
const playAudio = async () => { const playAudio = async () => {
try { try {
if (audio.muted) {
logger.error(
`${logPrefix} <audio> element was unexpectedly muted but we recovered ` +
`gracefully by unmuting it`,
);
// Recover gracefully
audio.muted = false;
}
// This still causes the chrome debugger to break on promise rejection if // This still causes the chrome debugger to break on promise rejection if
// the promise is rejected, even though we're catching the exception. // the promise is rejected, even though we're catching the exception.
logger.debug(`${logPrefix} attempting to play audio`); logger.debug(`${logPrefix} attempting to play audio at volume=${audio.volume}`);
await audio.play(); await audio.play();
logger.debug(`${logPrefix} playing audio successfully`); logger.debug(`${logPrefix} playing audio successfully`);
} catch (e) { } catch (e) {

View file

@ -96,8 +96,9 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
private async updateMode(mode: Mode) { private async updateMode(mode: Mode) {
this.setState({ phase: Phase.Loading }); this.setState({ phase: Phase.Loading });
if (this.state.rendezvous) { if (this.state.rendezvous) {
this.state.rendezvous.onFailure = undefined; const rendezvous = this.state.rendezvous;
await this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled); rendezvous.onFailure = undefined;
await rendezvous.cancel(RendezvousFailureReason.UserCancelled);
this.setState({ rendezvous: undefined }); this.setState({ rendezvous: undefined });
} }
if (mode === Mode.Show) { if (mode === Mode.Show) {

View file

@ -240,7 +240,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
{ _t("In reply to <a>this message</a>", { _t("In reply to <a>this message</a>",
{}, {},
{ a: (sub) => ( { a: (sub) => (
<a className="mx_reply_anchor" href={`#${eventId}`} scroll-to={eventId}> { sub } </a> <a className="mx_reply_anchor" href={`#${eventId}`} data-scroll-to={eventId}> { sub } </a>
), ),
}) })
} }

View file

@ -80,12 +80,13 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
if (!canChange && this.props.hideIfCannotSet) return null; if (!canChange && this.props.hideIfCannotSet) return null;
const label = this.props.label const label = (this.props.label
? _t(this.props.label) ? _t(this.props.label)
: SettingsStore.getDisplayName(this.props.name, this.props.level); : SettingsStore.getDisplayName(this.props.name, this.props.level)) ?? undefined;
const description = SettingsStore.getDescription(this.props.name); const description = SettingsStore.getDescription(this.props.name);
const shouldWarn = SettingsStore.shouldHaveWarning(this.props.name);
let disabledDescription: JSX.Element; let disabledDescription: JSX.Element | null = null;
if (this.props.disabled && this.props.disabledDescription) { if (this.props.disabled && this.props.disabledDescription) {
disabledDescription = <div className="mx_SettingsFlag_microcopy"> disabledDescription = <div className="mx_SettingsFlag_microcopy">
{ this.props.disabledDescription } { this.props.disabledDescription }
@ -106,7 +107,20 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
<label className="mx_SettingsFlag_label"> <label className="mx_SettingsFlag_label">
<span className="mx_SettingsFlag_labelText">{ label }</span> <span className="mx_SettingsFlag_labelText">{ label }</span>
{ description && <div className="mx_SettingsFlag_microcopy"> { description && <div className="mx_SettingsFlag_microcopy">
{ description } { shouldWarn
? _t(
"<w>WARNING:</w> <description/>", {},
{
"w": (sub) => (
<span className="mx_SettingsTab_microcopy_warning">
{ sub }
</span>
),
"description": description,
},
)
: description
}
</div> } </div> }
{ disabledDescription } { disabledDescription }
</label> </label>

View file

@ -747,13 +747,12 @@ export default class RoomSublist extends React.Component<IProps, IState> {
public render(): React.ReactElement { public render(): React.ReactElement {
const visibleTiles = this.renderVisibleTiles(); const visibleTiles = this.renderVisibleTiles();
const hidden = !this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true;
const classes = classNames({ const classes = classNames({
'mx_RoomSublist': true, 'mx_RoomSublist': true,
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition, 'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
'mx_RoomSublist_minimized': this.props.isMinimized, 'mx_RoomSublist_minimized': this.props.isMinimized,
'mx_RoomSublist_hidden': ( 'mx_RoomSublist_hidden': hidden,
!this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true
),
}); });
let content = null; let content = null;
@ -898,6 +897,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
ref={this.sublistRef} ref={this.sublistRef}
className={classes} className={classes}
role="group" role="group"
aria-hidden={hidden}
aria-label={this.props.label} aria-label={this.props.label}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
> >

View file

@ -64,12 +64,13 @@ const isDeviceSelected = (
) => selectedDeviceIds.includes(deviceId); ) => selectedDeviceIds.includes(deviceId);
// devices without timestamp metadata should be sorted last // devices without timestamp metadata should be sorted last
const sortDevicesByLatestActivity = (left: ExtendedDevice, right: ExtendedDevice) => const sortDevicesByLatestActivityThenDisplayName = (left: ExtendedDevice, right: ExtendedDevice) =>
(right.last_seen_ts || 0) - (left.last_seen_ts || 0); (right.last_seen_ts || 0) - (left.last_seen_ts || 0)
|| ((left.display_name || left.device_id).localeCompare(right.display_name || right.device_id));
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) => const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) =>
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : []) filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
.sort(sortDevicesByLatestActivity); .sort(sortDevicesByLatestActivityThenDisplayName);
const ALL_FILTER_ID = 'ALL'; const ALL_FILTER_ID = 'ALL';
type DeviceFilterKey = DeviceSecurityVariation | typeof ALL_FILTER_ID; type DeviceFilterKey = DeviceSecurityVariation | typeof ALL_FILTER_ID;

View file

@ -324,12 +324,13 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
let privilegedUsersSection = <div>{ _t('No users have specific privileges in this room') }</div>; let privilegedUsersSection = <div>{ _t('No users have specific privileges in this room') }</div>;
let mutedUsersSection; let mutedUsersSection;
if (Object.keys(userLevels).length) { if (Object.keys(userLevels).length) {
const privilegedUsers = []; const privilegedUsers: JSX.Element[] = [];
const mutedUsers = []; const mutedUsers: JSX.Element[] = [];
Object.keys(userLevels).forEach((user) => { Object.keys(userLevels).forEach((user) => {
if (!Number.isInteger(userLevels[user])) { return; } if (!Number.isInteger(userLevels[user])) return;
const canChange = userLevels[user] < currentUserLevel && canChangeLevels; const isMe = user === client.getUserId();
const canChange = canChangeLevels && (userLevels[user] < currentUserLevel || isMe);
if (userLevels[user] > defaultUserLevel) { // privileged if (userLevels[user] > defaultUserLevel) { // privileged
privilegedUsers.push( privilegedUsers.push(
<PowerSelector <PowerSelector

View file

@ -19,7 +19,6 @@ import { sortBy } from "lodash";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import { SettingLevel } from "../../../../../settings/SettingLevel"; import { SettingLevel } from "../../../../../settings/SettingLevel";
import SdkConfig from "../../../../../SdkConfig"; import SdkConfig from "../../../../../SdkConfig";
import BetaCard from "../../../beta/BetaCard"; import BetaCard from "../../../beta/BetaCard";
@ -28,24 +27,6 @@ import { MatrixClientPeg } from '../../../../../MatrixClientPeg';
import { LabGroup, labGroupNames } from "../../../../../settings/Settings"; import { LabGroup, labGroupNames } from "../../../../../settings/Settings";
import { EnhancedMap } from "../../../../../utils/maps"; import { EnhancedMap } from "../../../../../utils/maps";
interface ILabsSettingToggleProps {
featureId: string;
}
export class LabsSettingToggle extends React.Component<ILabsSettingToggleProps> {
private onChange = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue(this.props.featureId, null, SettingLevel.DEVICE, checked);
this.forceUpdate();
};
public render(): JSX.Element {
const label = SettingsStore.getDisplayName(this.props.featureId);
const value = SettingsStore.getValue(this.props.featureId);
const canChange = SettingsStore.canSetValue(this.props.featureId, null, SettingLevel.DEVICE);
return <LabelledToggleSwitch value={value} label={label} onChange={this.onChange} disabled={!canChange} />;
}
}
interface IState { interface IState {
showJumpToDate: boolean; showJumpToDate: boolean;
showExploringPublicSpaces: boolean; showExploringPublicSpaces: boolean;
@ -93,7 +74,7 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> {
const groups = new EnhancedMap<LabGroup, JSX.Element[]>(); const groups = new EnhancedMap<LabGroup, JSX.Element[]>();
labs.forEach(f => { labs.forEach(f => {
groups.getOrCreate(SettingsStore.getLabGroup(f), []).push( groups.getOrCreate(SettingsStore.getLabGroup(f), []).push(
<LabsSettingToggle featureId={f} key={f} />, <SettingsFlag level={SettingLevel.DEVICE} name={f} key={f} />,
); );
}); });
@ -154,24 +135,42 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> {
return ( return (
<div className="mx_SettingsTab mx_LabsUserSettingsTab"> <div className="mx_SettingsTab mx_LabsUserSettingsTab">
<div className="mx_SettingsTab_heading">{ _t("Labs") }</div> <div className="mx_SettingsTab_heading">{ _t("Upcoming features") }</div>
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
{ {
_t('Feeling experimental? Labs are the best way to get things early, ' + _t(
'test out new features and help shape them before they actually launch. ' + "What's next for %(brand)s? "
'<a>Learn more</a>.', {}, { + "Labs are the best way to get things early, "
'a': (sub) => { + "test out new features and help shape them before they actually launch.",
return <a { brand: SdkConfig.get("brand") },
href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md" )
rel='noreferrer noopener'
target='_blank'
>{ sub }</a>;
},
})
} }
</div> </div>
{ betaSection } { betaSection }
{ labsSections } { labsSections && <>
<div className="mx_SettingsTab_heading">{ _t("Early previews") }</div>
<div className='mx_SettingsTab_subsectionText'>
{
_t(
"Feeling experimental? "
+ "Try out our latest ideas in development. "
+ "These features are not finalised; "
+ "they may be unstable, may change, or may be dropped altogether. "
+ "<a>Learn more</a>.",
{},
{
'a': (sub) => {
return <a
href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md"
rel='noreferrer noopener'
target='_blank'
>{ sub }</a>;
},
})
}
</div>
{ labsSections }
</> }
</div> </div>
); );
} }

View file

@ -909,7 +909,8 @@
"Thank you for trying the beta, please go into as much detail as you can so we can improve it.": "Thank you for trying the beta, please go into as much detail as you can so we can improve it.", "Thank you for trying the beta, please go into as much detail as you can so we can improve it.": "Thank you for trying the beta, please go into as much detail as you can so we can improve it.",
"Explore public spaces in the new search dialog": "Explore public spaces in the new search dialog", "Explore public spaces in the new search dialog": "Explore public spaces in the new search dialog",
"Let moderators hide messages pending moderation.": "Let moderators hide messages pending moderation.", "Let moderators hide messages pending moderation.": "Let moderators hide messages pending moderation.",
"Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators", "Report to moderators": "Report to moderators",
"In rooms that support moderation, the “Report” button will let you report abuse to room moderators.": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.",
"Render LaTeX maths in messages": "Render LaTeX maths in messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages",
"Message Pinning": "Message Pinning", "Message Pinning": "Message Pinning",
"Threaded messaging": "Threaded messaging", "Threaded messaging": "Threaded messaging",
@ -921,9 +922,11 @@
"How can I leave the beta?": "How can I leave the beta?", "How can I leave the beta?": "How can I leave the beta?",
"To leave, return to this page and use the “%(leaveTheBeta)s” button.": "To leave, return to this page and use the “%(leaveTheBeta)s” button.", "To leave, return to this page and use the “%(leaveTheBeta)s” button.": "To leave, return to this page and use the “%(leaveTheBeta)s” button.",
"Leave the beta": "Leave the beta", "Leave the beta": "Leave the beta",
"Try out the rich text editor (plain text mode coming soon)": "Try out the rich text editor (plain text mode coming soon)", "Rich text editor": "Rich text editor",
"Use rich text instead of Markdown in the message composer. Plain text mode coming soon.": "Use rich text instead of Markdown in the message composer. Plain text mode coming soon.",
"Render simple counters in room header": "Render simple counters in room header", "Render simple counters in room header": "Render simple counters in room header",
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", "New ways to ignore people": "New ways to ignore people",
"Currently experimental.": "Currently experimental.",
"Support adding custom themes": "Support adding custom themes", "Support adding custom themes": "Support adding custom themes",
"Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in DMs": "Show message previews for reactions in DMs",
"Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms",
@ -933,15 +936,19 @@
"Show HTML representation of room topics": "Show HTML representation of room topics", "Show HTML representation of room topics": "Show HTML representation of room topics",
"Show info about bridges in room settings": "Show info about bridges in room settings", "Show info about bridges in room settings": "Show info about bridges in room settings",
"Use new room breadcrumbs": "Use new room breadcrumbs", "Use new room breadcrumbs": "Use new room breadcrumbs",
"Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)", "Right panel stays open": "Right panel stays open",
"Defaults to room member list.": "Defaults to room member list.",
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)", "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
"Send read receipts": "Send read receipts", "Send read receipts": "Send read receipts",
"Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)", "Sliding Sync mode": "Sliding Sync mode",
"Under active development, cannot be disabled.": "Under active development, cannot be disabled.",
"Element Call video rooms": "Element Call video rooms", "Element Call video rooms": "Element Call video rooms",
"New group call experience": "New group call experience", "New group call experience": "New group call experience",
"Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Live Location Sharing": "Live Location Sharing",
"Favourite Messages (under active development)": "Favourite Messages (under active development)", "Temporary implementation. Locations persist in room history.": "Temporary implementation. Locations persist in room history.",
"Voice broadcast (under active development)": "Voice broadcast (under active development)", "Favourite Messages": "Favourite Messages",
"Under active development.": "Under active development.",
"Under active development": "Under active development",
"Use new session manager": "Use new session manager", "Use new session manager": "Use new session manager",
"New session manager": "New session manager", "New session manager": "New session manager",
"Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.", "Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.",
@ -1002,7 +1009,8 @@
"Show shortcuts to recently viewed rooms above the room list": "Show shortcuts to recently viewed rooms above the room list", "Show shortcuts to recently viewed rooms above the room list": "Show shortcuts to recently viewed rooms above the room list",
"Show shortcut to welcome checklist above the room list": "Show shortcut to welcome checklist above the room list", "Show shortcut to welcome checklist above the room list": "Show shortcut to welcome checklist above the room list",
"Show hidden events in timeline": "Show hidden events in timeline", "Show hidden events in timeline": "Show hidden events in timeline",
"Low bandwidth mode (requires compatible homeserver)": "Low bandwidth mode (requires compatible homeserver)", "Low bandwidth mode": "Low bandwidth mode",
"Requires compatible homeserver.": "Requires compatible homeserver.",
"Allow fallback call assist server (turn.matrix.org)": "Allow fallback call assist server (turn.matrix.org)", "Allow fallback call assist server (turn.matrix.org)": "Allow fallback call assist server (turn.matrix.org)",
"Only applies if your homeserver does not offer one. Your IP address would be shared during a call.": "Only applies if your homeserver does not offer one. Your IP address would be shared during a call.", "Only applies if your homeserver does not offer one. Your IP address would be shared during a call.": "Only applies if your homeserver does not offer one. Your IP address would be shared during a call.",
"Show previews/thumbnails for images": "Show previews/thumbnails for images", "Show previews/thumbnails for images": "Show previews/thumbnails for images",
@ -1540,8 +1548,10 @@
"Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.", "Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.",
"Clear cache and reload": "Clear cache and reload", "Clear cache and reload": "Clear cache and reload",
"Keyboard": "Keyboard", "Keyboard": "Keyboard",
"Labs": "Labs", "Upcoming features": "Upcoming features",
"Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.", "What's next for %(brand)s? Labs are the best way to get things early, test out new features and help shape them before they actually launch.": "What's next for %(brand)s? Labs are the best way to get things early, test out new features and help shape them before they actually launch.",
"Early previews": "Early previews",
"Feeling experimental? Try out our latest ideas in development. These features are not finalised; they may be unstable, may change, or may be dropped altogether. <a>Learn more</a>.": "Feeling experimental? Try out our latest ideas in development. These features are not finalised; they may be unstable, may change, or may be dropped altogether. <a>Learn more</a>.",
"Ignored/Blocked": "Ignored/Blocked", "Ignored/Blocked": "Ignored/Blocked",
"Error adding ignored user/server": "Error adding ignored user/server", "Error adding ignored user/server": "Error adding ignored user/server",
"Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.", "Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.",
@ -2563,6 +2573,7 @@
"Join millions for free on the largest public server": "Join millions for free on the largest public server", "Join millions for free on the largest public server": "Join millions for free on the largest public server",
"Homeserver": "Homeserver", "Homeserver": "Homeserver",
"Help": "Help", "Help": "Help",
"<w>WARNING:</w> <description/>": "<w>WARNING:</w> <description/>",
"Choose a locale": "Choose a locale", "Choose a locale": "Choose a locale",
"Continue with %(provider)s": "Continue with %(provider)s", "Continue with %(provider)s": "Continue with %(provider)s",
"Sign in with single sign-on": "Sign in with single sign-on", "Sign in with single sign-on": "Sign in with single sign-on",
@ -2995,6 +3006,7 @@
"Upload %(count)s other files|one": "Upload %(count)s other file", "Upload %(count)s other files|one": "Upload %(count)s other file",
"Cancel All": "Cancel All", "Cancel All": "Cancel All",
"Upload Error": "Upload Error", "Upload Error": "Upload Error",
"Labs": "Labs",
"Verify other device": "Verify other device", "Verify other device": "Verify other device",
"Verification Request": "Verification Request", "Verification Request": "Verification Request",
"Approve widget permissions": "Approve widget permissions", "Approve widget permissions": "Approve widget permissions",

View file

@ -122,13 +122,13 @@ export const labGroupNames: Record<LabGroup, string> = {
[LabGroup.Developer]: _td("Developer"), [LabGroup.Developer]: _td("Developer"),
}; };
export type SettingValueType = boolean | export type SettingValueType = boolean
number | | number
string | | string
number[] | | number[]
string[] | | string[]
Record<string, unknown> | | Record<string, unknown>
null; | null;
export interface IBaseSetting<T extends SettingValueType = SettingValueType> { export interface IBaseSetting<T extends SettingValueType = SettingValueType> {
isFeature?: false | undefined; isFeature?: false | undefined;
@ -180,6 +180,9 @@ export interface IBaseSetting<T extends SettingValueType = SettingValueType> {
extraSettings?: string[]; extraSettings?: string[];
requiresRefresh?: boolean; requiresRefresh?: boolean;
}; };
// Whether the setting should have a warning sign in the microcopy
shouldWarn?: boolean;
} }
export interface IFeature extends Omit<IBaseSetting<boolean>, "isFeature"> { export interface IFeature extends Omit<IBaseSetting<boolean>, "isFeature"> {
@ -245,8 +248,11 @@ export const SETTINGS: {[setting: string]: ISetting} = {
"feature_report_to_moderators": { "feature_report_to_moderators": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Moderation, labsGroup: LabGroup.Moderation,
displayName: _td("Report to moderators prototype. " + displayName: _td("Report to moderators"),
"In rooms that support moderation, the `report` button will let you report abuse to room moderators"), description: _td(
"In rooms that support moderation, "
+"the “Report” button will let you report abuse to room moderators.",
),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
@ -307,7 +313,8 @@ export const SETTINGS: {[setting: string]: ISetting} = {
"feature_wysiwyg_composer": { "feature_wysiwyg_composer": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,
displayName: _td("Try out the rich text editor (plain text mode coming soon)"), displayName: _td("Rich text editor"),
description: _td("Use rich text instead of Markdown in the message composer. Plain text mode coming soon."),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
@ -321,7 +328,8 @@ export const SETTINGS: {[setting: string]: ISetting} = {
"feature_mjolnir": { "feature_mjolnir": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Moderation, labsGroup: LabGroup.Moderation,
displayName: _td("Try out new ways to ignore people (experimental)"), displayName: _td("New ways to ignore people"),
description: _td("Currently experimental."),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
@ -400,7 +408,8 @@ export const SETTINGS: {[setting: string]: ISetting} = {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Rooms, labsGroup: LabGroup.Rooms,
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
displayName: _td("Right panel stays open (defaults to room member list)"), displayName: _td("Right panel stays open"),
description: _td("Defaults to room member list."),
default: false, default: false,
}, },
"feature_jump_to_date": { "feature_jump_to_date": {
@ -425,7 +434,9 @@ export const SETTINGS: {[setting: string]: ISetting} = {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Developer, labsGroup: LabGroup.Developer,
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
displayName: _td('Sliding Sync mode (under active development, cannot be disabled)'), displayName: _td('Sliding Sync mode'),
description: _td("Under active development, cannot be disabled."),
shouldWarn: true,
default: false, default: false,
controller: new SlidingSyncController(), controller: new SlidingSyncController(),
}, },
@ -453,23 +464,25 @@ export const SETTINGS: {[setting: string]: ISetting} = {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
displayName: _td( displayName: _td("Live Location Sharing"),
"Live Location Sharing (temporary implementation: locations persist in room history)", description: _td("Temporary implementation. Locations persist in room history."),
), shouldWarn: true,
default: false, default: false,
}, },
"feature_favourite_messages": { "feature_favourite_messages": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
displayName: _td("Favourite Messages (under active development)"), displayName: _td("Favourite Messages"),
description: _td("Under active development."),
default: false, default: false,
}, },
[Features.VoiceBroadcast]: { [Features.VoiceBroadcast]: {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
displayName: _td("Voice broadcast (under active development)"), displayName: _td("Voice broadcast"),
description: _td("Under active development"),
default: false, default: false,
}, },
"feature_new_device_manager": { "feature_new_device_manager": {
@ -910,9 +923,11 @@ export const SETTINGS: {[setting: string]: ISetting} = {
}, },
"lowBandwidth": { "lowBandwidth": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
displayName: _td('Low bandwidth mode (requires compatible homeserver)'), displayName: _td('Low bandwidth mode'),
description: _td("Requires compatible homeserver."),
default: false, default: false,
controller: new ReloadOnChangeController(), controller: new ReloadOnChangeController(),
shouldWarn: true,
}, },
"fallbackICEServerAllowed": { "fallbackICEServerAllowed": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
@ -1056,6 +1071,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: false, default: false,
}, },
"debug_legacy_call_handler": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: false,
},
"audioInputMuted": { "audioInputMuted": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: false, default: false,

View file

@ -295,6 +295,16 @@ export default class SettingsStore {
return SETTINGS[settingName].isFeature; return SETTINGS[settingName].isFeature;
} }
/**
* Determines if a setting should have a warning sign in the microcopy
* @param {string} settingName The setting to look up.
* @return {boolean} True if the setting should have a warning sign.
*/
public static shouldHaveWarning(settingName: string): boolean {
if (!SETTINGS[settingName]) return false;
return SETTINGS[settingName].shouldWarn ?? false;
}
public static getBetaInfo(settingName: string): ISetting["betaInfo"] { public static getBetaInfo(settingName: string): ISetting["betaInfo"] {
// consider a beta disabled if the config is explicitly set to false, in which case treat as normal Labs flag // consider a beta disabled if the config is explicitly set to false, in which case treat as normal Labs flag
if (SettingsStore.isFeature(settingName) if (SettingsStore.isFeature(settingName)
@ -355,7 +365,7 @@ export default class SettingsStore {
public static getValueAt( public static getValueAt(
level: SettingLevel, level: SettingLevel,
settingName: string, settingName: string,
roomId: string = null, roomId: string | null = null,
explicit = false, explicit = false,
excludeDefault = false, excludeDefault = false,
): any { ): any {
@ -420,7 +430,7 @@ export default class SettingsStore {
private static getFinalValue( private static getFinalValue(
setting: ISetting, setting: ISetting,
level: SettingLevel, level: SettingLevel,
roomId: string, roomId: string | null,
calculatedValue: any, calculatedValue: any,
calculatedAtLevel: SettingLevel, calculatedAtLevel: SettingLevel,
): any { ): any {

View file

@ -39,7 +39,7 @@ export default abstract class SettingController {
*/ */
public getValueOverride( public getValueOverride(
level: SettingLevel, level: SettingLevel,
roomId: string, roomId: string | null,
calculatedValue: any, calculatedValue: any,
calculatedAtLevel: SettingLevel, calculatedAtLevel: SettingLevel,
): any { ): any {

View file

@ -35,7 +35,7 @@ function showToast(text) {
window.onload = () => { window.onload = () => {
document.querySelectorAll('.mx_reply_anchor').forEach(element => { document.querySelectorAll('.mx_reply_anchor').forEach(element => {
element.addEventListener('click', event => { element.addEventListener('click', event => {
showToastIfNeeded(event.target.getAttribute("scroll-to")); showToastIfNeeded(event.target.dataset.scrollTo);
}); });
}); });
}; };

View file

@ -15,10 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events"; import { Mocked, mocked } from "jest-mock";
import { mocked } from "jest-mock"; import { MatrixEvent, Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
import { CrossSigningInfo, DeviceTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import DeviceListener from "../src/DeviceListener"; import DeviceListener from "../src/DeviceListener";
import { MatrixClientPeg } from "../src/MatrixClientPeg"; import { MatrixClientPeg } from "../src/MatrixClientPeg";
@ -30,7 +33,7 @@ import dis from "../src/dispatcher/dispatcher";
import { Action } from "../src/dispatcher/actions"; import { Action } from "../src/dispatcher/actions";
import SettingsStore from "../src/settings/SettingsStore"; import SettingsStore from "../src/settings/SettingsStore";
import { SettingLevel } from "../src/settings/SettingLevel"; import { SettingLevel } from "../src/settings/SettingLevel";
import { mockPlatformPeg } from "./test-utils"; import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils";
// don't litter test console with logs // don't litter test console with logs
jest.mock("matrix-js-sdk/src/logger"); jest.mock("matrix-js-sdk/src/logger");
@ -44,35 +47,13 @@ jest.mock("../src/SecurityManager", () => ({
isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(),
})); }));
const userId = '@user:server';
const deviceId = 'my-device-id'; const deviceId = 'my-device-id';
class MockClient extends EventEmitter {
isGuest = jest.fn();
getUserId = jest.fn();
getKeyBackupVersion = jest.fn().mockResolvedValue(undefined);
getRooms = jest.fn().mockReturnValue([]);
doesServerSupportUnstableFeature = jest.fn().mockResolvedValue(true);
isCrossSigningReady = jest.fn().mockResolvedValue(true);
isSecretStorageReady = jest.fn().mockResolvedValue(true);
isCryptoEnabled = jest.fn().mockReturnValue(true);
isInitialSyncComplete = jest.fn().mockReturnValue(true);
getKeyBackupEnabled = jest.fn();
getStoredDevicesForUser = jest.fn().mockReturnValue([]);
getCrossSigningId = jest.fn();
getStoredCrossSigningForUser = jest.fn();
waitForClientWellKnown = jest.fn();
downloadKeys = jest.fn();
isRoomEncrypted = jest.fn();
getClientWellKnown = jest.fn();
getDeviceId = jest.fn().mockReturnValue(deviceId);
setAccountData = jest.fn();
getAccountData = jest.fn();
}
const mockDispatcher = mocked(dis); const mockDispatcher = mocked(dis);
const flushPromises = async () => await new Promise(process.nextTick); const flushPromises = async () => await new Promise(process.nextTick);
describe('DeviceListener', () => { describe('DeviceListener', () => {
let mockClient; let mockClient: Mocked<MatrixClient> | undefined;
// spy on various toasts' hide and show functions // spy on various toasts' hide and show functions
// easier than mocking // easier than mocking
@ -88,7 +69,29 @@ describe('DeviceListener', () => {
mockPlatformPeg({ mockPlatformPeg({
getAppVersion: jest.fn().mockResolvedValue('1.2.3'), getAppVersion: jest.fn().mockResolvedValue('1.2.3'),
}); });
mockClient = new MockClient(); mockClient = getMockClientWithEventEmitter({
isGuest: jest.fn(),
getUserId: jest.fn().mockReturnValue(userId),
getKeyBackupVersion: jest.fn().mockResolvedValue(undefined),
getRooms: jest.fn().mockReturnValue([]),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(true),
isCrossSigningReady: jest.fn().mockResolvedValue(true),
isSecretStorageReady: jest.fn().mockResolvedValue(true),
isCryptoEnabled: jest.fn().mockReturnValue(true),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
getKeyBackupEnabled: jest.fn(),
getStoredDevicesForUser: jest.fn().mockReturnValue([]),
getCrossSigningId: jest.fn(),
getStoredCrossSigningForUser: jest.fn(),
waitForClientWellKnown: jest.fn(),
downloadKeys: jest.fn(),
isRoomEncrypted: jest.fn(),
getClientWellKnown: jest.fn(),
getDeviceId: jest.fn().mockReturnValue(deviceId),
setAccountData: jest.fn(),
getAccountData: jest.fn(),
checkDeviceTrust: jest.fn().mockReturnValue(new DeviceTrustLevel(false, false, false, false)),
});
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient);
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
}); });
@ -124,7 +127,7 @@ describe('DeviceListener', () => {
it('saves client information on start', async () => { it('saves client information on start', async () => {
await createAndStart(); await createAndStart();
expect(mockClient.setAccountData).toHaveBeenCalledWith( expect(mockClient!.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`, `io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' }, { name: 'Element', url: 'localhost', version: '1.2.3' },
); );
@ -133,7 +136,7 @@ describe('DeviceListener', () => {
it('catches error and logs when saving client information fails', async () => { it('catches error and logs when saving client information fails', async () => {
const errorLogSpy = jest.spyOn(logger, 'error'); const errorLogSpy = jest.spyOn(logger, 'error');
const error = new Error('oups'); const error = new Error('oups');
mockClient.setAccountData.mockRejectedValue(error); mockClient!.setAccountData.mockRejectedValue(error);
// doesn't throw // doesn't throw
await createAndStart(); await createAndStart();
@ -147,14 +150,14 @@ describe('DeviceListener', () => {
it('saves client information on logged in action', async () => { it('saves client information on logged in action', async () => {
const instance = await createAndStart(); const instance = await createAndStart();
mockClient.setAccountData.mockClear(); mockClient!.setAccountData.mockClear();
// @ts-ignore calling private function // @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn }); instance.onAction({ action: Action.OnLoggedIn });
await flushPromises(); await flushPromises();
expect(mockClient.setAccountData).toHaveBeenCalledWith( expect(mockClient!.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`, `io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' }, { name: 'Element', url: 'localhost', version: '1.2.3' },
); );
@ -169,30 +172,30 @@ describe('DeviceListener', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
mockClient.getAccountData.mockReturnValue(undefined); mockClient!.getAccountData.mockReturnValue(undefined);
}); });
it('does not save client information on start', async () => { it('does not save client information on start', async () => {
await createAndStart(); await createAndStart();
expect(mockClient.setAccountData).not.toHaveBeenCalled(); expect(mockClient!.setAccountData).not.toHaveBeenCalled();
}); });
it('removes client information on start if it exists', async () => { it('removes client information on start if it exists', async () => {
mockClient.getAccountData.mockReturnValue(clientInfoEvent); mockClient!.getAccountData.mockReturnValue(clientInfoEvent);
await createAndStart(); await createAndStart();
expect(mockClient.setAccountData).toHaveBeenCalledWith( expect(mockClient!.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`, `io.element.matrix_client_information.${deviceId}`,
{}, {},
); );
}); });
it('does not try to remove client info event that are already empty', async () => { it('does not try to remove client info event that are already empty', async () => {
mockClient.getAccountData.mockReturnValue(emptyClientInfoEvent); mockClient!.getAccountData.mockReturnValue(emptyClientInfoEvent);
await createAndStart(); await createAndStart();
expect(mockClient.setAccountData).not.toHaveBeenCalled(); expect(mockClient!.setAccountData).not.toHaveBeenCalled();
}); });
it('does not save client information on logged in action', async () => { it('does not save client information on logged in action', async () => {
@ -203,7 +206,7 @@ describe('DeviceListener', () => {
await flushPromises(); await flushPromises();
expect(mockClient.setAccountData).not.toHaveBeenCalled(); expect(mockClient!.setAccountData).not.toHaveBeenCalled();
}); });
it('saves client information after setting is enabled', async () => { it('saves client information after setting is enabled', async () => {
@ -218,7 +221,7 @@ describe('DeviceListener', () => {
await flushPromises(); await flushPromises();
expect(mockClient.setAccountData).toHaveBeenCalledWith( expect(mockClient!.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`, `io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' }, { name: 'Element', url: 'localhost', version: '1.2.3' },
); );
@ -228,22 +231,22 @@ describe('DeviceListener', () => {
describe('recheck', () => { describe('recheck', () => {
it('does nothing when cross signing feature is not supported', async () => { it('does nothing when cross signing feature is not supported', async () => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false); mockClient!.doesServerSupportUnstableFeature.mockResolvedValue(false);
await createAndStart(); await createAndStart();
expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); expect(mockClient!.isCrossSigningReady).not.toHaveBeenCalled();
}); });
it('does nothing when crypto is not enabled', async () => { it('does nothing when crypto is not enabled', async () => {
mockClient.isCryptoEnabled.mockReturnValue(false); mockClient!.isCryptoEnabled.mockReturnValue(false);
await createAndStart(); await createAndStart();
expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); expect(mockClient!.isCrossSigningReady).not.toHaveBeenCalled();
}); });
it('does nothing when initial sync is not complete', async () => { it('does nothing when initial sync is not complete', async () => {
mockClient.isInitialSyncComplete.mockReturnValue(false); mockClient!.isInitialSyncComplete.mockReturnValue(false);
await createAndStart(); await createAndStart();
expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); expect(mockClient!.isCrossSigningReady).not.toHaveBeenCalled();
}); });
describe('set up encryption', () => { describe('set up encryption', () => {
@ -253,15 +256,15 @@ describe('DeviceListener', () => {
] as unknown as Room[]; ] as unknown as Room[];
beforeEach(() => { beforeEach(() => {
mockClient.isCrossSigningReady.mockResolvedValue(false); mockClient!.isCrossSigningReady.mockResolvedValue(false);
mockClient.isSecretStorageReady.mockResolvedValue(false); mockClient!.isSecretStorageReady.mockResolvedValue(false);
mockClient.getRooms.mockReturnValue(rooms); mockClient!.getRooms.mockReturnValue(rooms);
mockClient.isRoomEncrypted.mockReturnValue(true); mockClient!.isRoomEncrypted.mockReturnValue(true);
}); });
it('hides setup encryption toast when cross signing and secret storage are ready', async () => { it('hides setup encryption toast when cross signing and secret storage are ready', async () => {
mockClient.isCrossSigningReady.mockResolvedValue(true); mockClient!.isCrossSigningReady.mockResolvedValue(true);
mockClient.isSecretStorageReady.mockResolvedValue(true); mockClient!.isSecretStorageReady.mockResolvedValue(true);
await createAndStart(); await createAndStart();
expect(SetupEncryptionToast.hideToast).toHaveBeenCalled(); expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
}); });
@ -277,49 +280,49 @@ describe('DeviceListener', () => {
mocked(isSecretStorageBeingAccessed).mockReturnValue(true); mocked(isSecretStorageBeingAccessed).mockReturnValue(true);
await createAndStart(); await createAndStart();
expect(mockClient.downloadKeys).not.toHaveBeenCalled(); expect(mockClient!.downloadKeys).not.toHaveBeenCalled();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled();
}); });
it('does not do any checks or show any toasts when no rooms are encrypted', async () => { it('does not do any checks or show any toasts when no rooms are encrypted', async () => {
mockClient.isRoomEncrypted.mockReturnValue(false); mockClient!.isRoomEncrypted.mockReturnValue(false);
await createAndStart(); await createAndStart();
expect(mockClient.downloadKeys).not.toHaveBeenCalled(); expect(mockClient!.downloadKeys).not.toHaveBeenCalled();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled();
}); });
describe('when user does not have a cross signing id on this device', () => { describe('when user does not have a cross signing id on this device', () => {
beforeEach(() => { beforeEach(() => {
mockClient.getCrossSigningId.mockReturnValue(undefined); mockClient!.getCrossSigningId.mockReturnValue(null);
}); });
it('shows verify session toast when account has cross signing', async () => { it('shows verify session toast when account has cross signing', async () => {
mockClient.getStoredCrossSigningForUser.mockReturnValue(true); mockClient!.getStoredCrossSigningForUser.mockReturnValue(new CrossSigningInfo(userId));
await createAndStart(); await createAndStart();
expect(mockClient.downloadKeys).toHaveBeenCalled(); expect(mockClient!.downloadKeys).toHaveBeenCalled();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.VERIFY_THIS_SESSION); SetupEncryptionToast.Kind.VERIFY_THIS_SESSION);
}); });
it('checks key backup status when when account has cross signing', async () => { it('checks key backup status when when account has cross signing', async () => {
mockClient.getCrossSigningId.mockReturnValue(undefined); mockClient!.getCrossSigningId.mockReturnValue(null);
mockClient.getStoredCrossSigningForUser.mockReturnValue(true); mockClient!.getStoredCrossSigningForUser.mockReturnValue(new CrossSigningInfo(userId));
await createAndStart(); await createAndStart();
expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalled();
}); });
}); });
describe('when user does have a cross signing id on this device', () => { describe('when user does have a cross signing id on this device', () => {
beforeEach(() => { beforeEach(() => {
mockClient.getCrossSigningId.mockReturnValue('abc'); mockClient!.getCrossSigningId.mockReturnValue('abc');
}); });
it('shows upgrade encryption toast when user has a key backup available', async () => { it('shows upgrade encryption toast when user has a key backup available', async () => {
// non falsy response // non falsy response
mockClient.getKeyBackupVersion.mockResolvedValue({}); mockClient!.getKeyBackupVersion.mockResolvedValue({} as unknown as IKeyBackupInfo);
await createAndStart(); await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
@ -332,51 +335,167 @@ describe('DeviceListener', () => {
it('checks keybackup status when cross signing and secret storage are ready', async () => { it('checks keybackup status when cross signing and secret storage are ready', async () => {
// default mocks set cross signing and secret storage to ready // default mocks set cross signing and secret storage to ready
await createAndStart(); await createAndStart();
expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalled();
expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
}); });
it('checks keybackup status when setup encryption toast has been dismissed', async () => { it('checks keybackup status when setup encryption toast has been dismissed', async () => {
mockClient.isCrossSigningReady.mockResolvedValue(false); mockClient!.isCrossSigningReady.mockResolvedValue(false);
const instance = await createAndStart(); const instance = await createAndStart();
instance.dismissEncryptionSetup(); instance.dismissEncryptionSetup();
await flushPromises(); await flushPromises();
expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalled();
}); });
it('does not dispatch keybackup event when key backup check is not finished', async () => { it('does not dispatch keybackup event when key backup check is not finished', async () => {
// returns null when key backup status hasn't finished being checked // returns null when key backup status hasn't finished being checked
mockClient.getKeyBackupEnabled.mockReturnValue(null); mockClient!.getKeyBackupEnabled.mockReturnValue(null);
await createAndStart(); await createAndStart();
expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
}); });
it('dispatches keybackup event when key backup is not enabled', async () => { it('dispatches keybackup event when key backup is not enabled', async () => {
mockClient.getKeyBackupEnabled.mockReturnValue(false); mockClient!.getKeyBackupEnabled.mockReturnValue(false);
await createAndStart(); await createAndStart();
expect(mockDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ReportKeyBackupNotEnabled }); expect(mockDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ReportKeyBackupNotEnabled });
}); });
it('does not check key backup status again after check is complete', async () => { it('does not check key backup status again after check is complete', async () => {
mockClient.getKeyBackupEnabled.mockReturnValue(null); mockClient!.getKeyBackupEnabled.mockReturnValue(null);
const instance = await createAndStart(); const instance = await createAndStart();
expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalled();
// keyback check now complete // keyback check now complete
mockClient.getKeyBackupEnabled.mockReturnValue(true); mockClient!.getKeyBackupEnabled.mockReturnValue(true);
// trigger a recheck // trigger a recheck
instance.dismissEncryptionSetup(); instance.dismissEncryptionSetup();
await flushPromises(); await flushPromises();
expect(mockClient.getKeyBackupEnabled).toHaveBeenCalledTimes(2); expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalledTimes(2);
// trigger another recheck // trigger another recheck
instance.dismissEncryptionSetup(); instance.dismissEncryptionSetup();
await flushPromises(); await flushPromises();
// not called again, check was complete last time // not called again, check was complete last time
expect(mockClient.getKeyBackupEnabled).toHaveBeenCalledTimes(2); expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalledTimes(2);
});
});
describe('unverified sessions toasts', () => {
const currentDevice = new DeviceInfo(deviceId);
const device2 = new DeviceInfo('d2');
const device3 = new DeviceInfo('d3');
const deviceTrustVerified = new DeviceTrustLevel(true, false, false, false);
const deviceTrustUnverified = new DeviceTrustLevel(false, false, false, false);
beforeEach(() => {
mockClient!.isCrossSigningReady.mockResolvedValue(true);
mockClient!.getStoredDevicesForUser.mockReturnValue([
currentDevice, device2, device3,
]);
// all devices verified by default
mockClient!.checkDeviceTrust.mockReturnValue(deviceTrustVerified);
mockClient!.deviceId = currentDevice.deviceId;
});
describe('bulk unverified sessions toasts', () => {
it('hides toast when cross signing is not ready', async () => {
mockClient!.isCrossSigningReady.mockResolvedValue(false);
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
});
it('hides toast when all devices at app start are verified', async () => {
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
});
it('hides toast when only unverified device is the current device', async () => {
mockClient!.getStoredDevicesForUser.mockReturnValue([
currentDevice,
]);
mockClient!.checkDeviceTrust.mockReturnValue(deviceTrustUnverified);
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
});
it('shows toast with unverified devices at app start', async () => {
// currentDevice, device2 are verified, device3 is unverified
mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => {
switch (deviceId) {
case currentDevice.deviceId:
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
await createAndStart();
expect(BulkUnverifiedSessionsToast.showToast).toHaveBeenCalledWith(
new Set<string>([device3.deviceId]),
);
expect(BulkUnverifiedSessionsToast.hideToast).not.toHaveBeenCalled();
});
it('hides toast when unverified sessions at app start have been dismissed', async () => {
// currentDevice, device2 are verified, device3 is unverified
mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => {
switch (deviceId) {
case currentDevice.deviceId:
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
const instance = await createAndStart();
expect(BulkUnverifiedSessionsToast.showToast).toHaveBeenCalledWith(
new Set<string>([device3.deviceId]),
);
await instance.dismissUnverifiedSessions([device3.deviceId]);
await flushPromises();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
});
it('hides toast when unverified sessions are added after app start', async () => {
// currentDevice, device2 are verified, device3 is unverified
mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => {
switch (deviceId) {
case currentDevice.deviceId:
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
mockClient!.getStoredDevicesForUser.mockReturnValue([
currentDevice, device2,
]);
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
// add an unverified device
mockClient!.getStoredDevicesForUser.mockReturnValue([
currentDevice, device2, device3,
]);
// trigger a recheck
mockClient!.emit(CryptoEvent.DevicesUpdated, [userId], false);
await flushPromises();
// bulk unverified sessions toast only shown for devices that were
// there at app start
// individual nags are shown for new unverified devices
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalledTimes(2);
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
});
}); });
}); });
}); });

View file

@ -28,7 +28,7 @@ import { mocked } from 'jest-mock';
import { CallEventHandlerEvent } from 'matrix-js-sdk/src/webrtc/callEventHandler'; import { CallEventHandlerEvent } from 'matrix-js-sdk/src/webrtc/callEventHandler';
import LegacyCallHandler, { import LegacyCallHandler, {
LegacyCallHandlerEvent, PROTOCOL_PSTN, PROTOCOL_PSTN_PREFIXED, PROTOCOL_SIP_NATIVE, PROTOCOL_SIP_VIRTUAL, LegacyCallHandlerEvent, AudioID, PROTOCOL_PSTN, PROTOCOL_PSTN_PREFIXED, PROTOCOL_SIP_NATIVE, PROTOCOL_SIP_VIRTUAL,
} from '../src/LegacyCallHandler'; } from '../src/LegacyCallHandler';
import { stubClient, mkStubRoom, untilDispatch } from './test-utils'; import { stubClient, mkStubRoom, untilDispatch } from './test-utils';
import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { MatrixClientPeg } from '../src/MatrixClientPeg';
@ -445,6 +445,9 @@ describe('LegacyCallHandler without third party protocols', () => {
const mockAudioElement = { const mockAudioElement = {
play: jest.fn(), play: jest.fn(),
pause: jest.fn(), pause: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
muted: false,
} as unknown as HTMLMediaElement; } as unknown as HTMLMediaElement;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@ -488,6 +491,19 @@ describe('LegacyCallHandler without third party protocols', () => {
}); });
}); });
it('should unmute <audio> before playing', () => {
// Test setup: set the audio element as muted
mockAudioElement.muted = true;
expect(mockAudioElement.muted).toStrictEqual(true);
callHandler.play(AudioID.Ring);
// Ensure audio is no longer muted
expect(mockAudioElement.muted).toStrictEqual(false);
// Ensure the audio was played
expect(mockAudioElement.play).toHaveBeenCalled();
});
it('listens for incoming call events when voip is enabled', () => { it('listens for incoming call events when voip is enabled', () => {
const call = new MatrixCall({ const call = new MatrixCall({
client: MatrixClientPeg.get(), client: MatrixClientPeg.get(),

View file

@ -18,6 +18,9 @@ import React from "react";
import { fireEvent, render, RenderResult } from "@testing-library/react"; import { fireEvent, render, RenderResult } from "@testing-library/react";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { mocked } from "jest-mock";
import RolesRoomSettingsTab from "../../../../../../src/components/views/settings/tabs/room/RolesRoomSettingsTab"; import RolesRoomSettingsTab from "../../../../../../src/components/views/settings/tabs/room/RolesRoomSettingsTab";
import { mkStubRoom, stubClient } from "../../../../../test-utils"; import { mkStubRoom, stubClient } from "../../../../../test-utils";
@ -29,23 +32,52 @@ import { ElementCall } from "../../../../../../src/models/Call";
describe("RolesRoomSettingsTab", () => { describe("RolesRoomSettingsTab", () => {
const roomId = "!room:example.com"; const roomId = "!room:example.com";
let cli: MatrixClient; let cli: MatrixClient;
let room: Room;
const renderTab = (): RenderResult => { const renderTab = (): RenderResult => {
return render(<RolesRoomSettingsTab roomId={roomId} />); return render(<RolesRoomSettingsTab roomId={roomId} />);
}; };
const getVoiceBroadcastsSelect = () => { const getVoiceBroadcastsSelect = (): HTMLElement => {
return renderTab().container.querySelector("select[label='Voice broadcasts']"); return renderTab().container.querySelector("select[label='Voice broadcasts']");
}; };
const getVoiceBroadcastsSelectedOption = () => { const getVoiceBroadcastsSelectedOption = (): HTMLElement => {
return renderTab().container.querySelector("select[label='Voice broadcasts'] option:checked"); return renderTab().container.querySelector("select[label='Voice broadcasts'] option:checked");
}; };
beforeEach(() => { beforeEach(() => {
stubClient(); stubClient();
cli = MatrixClientPeg.get(); cli = MatrixClientPeg.get();
mkStubRoom(roomId, "test room", cli); room = mkStubRoom(roomId, "test room", cli);
});
it("should allow an Admin to demote themselves but not others", () => {
mocked(cli.getRoom).mockReturnValue(room);
// @ts-ignore - mocked doesn't support overloads properly
mocked(room.currentState.getStateEvents).mockImplementation((type, key) => {
if (key === undefined) return [] as MatrixEvent[];
if (type === "m.room.power_levels") {
return new MatrixEvent({
sender: "@sender:server",
room_id: roomId,
type: "m.room.power_levels",
state_key: "",
content: {
users: {
[cli.getUserId()]: 100,
"@admin:server": 100,
},
},
});
}
return null;
});
mocked(room.currentState.mayClientSendStateEvent).mockReturnValue(true);
const { container } = renderTab();
expect(container.querySelector(`[placeholder="${cli.getUserId()}"]`)).not.toBeDisabled();
expect(container.querySelector(`[placeholder="@admin:server"]`)).toBeDisabled();
}); });
it("should initially show »Moderator« permission for »Voice broadcasts«", () => { it("should initially show »Moderator« permission for »Voice broadcasts«", () => {
@ -79,19 +111,19 @@ describe("RolesRoomSettingsTab", () => {
}); });
}; };
const getStartCallSelect = (tab: RenderResult) => { const getStartCallSelect = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("select[label='Start Element Call calls']"); return tab.container.querySelector("select[label='Start Element Call calls']");
}; };
const getStartCallSelectedOption = (tab: RenderResult) => { const getStartCallSelectedOption = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("select[label='Start Element Call calls'] option:checked"); return tab.container.querySelector("select[label='Start Element Call calls'] option:checked");
}; };
const getJoinCallSelect = (tab: RenderResult) => { const getJoinCallSelect = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("select[label='Join Element Call calls']"); return tab.container.querySelector("select[label='Join Element Call calls']");
}; };
const getJoinCallSelectedOption = (tab: RenderResult) => { const getJoinCallSelectedOption = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("select[label='Join Element Call calls'] option:checked"); return tab.container.querySelector("select[label='Join Element Call calls'] option:checked");
}; };

3798
yarn.lock

File diff suppressed because it is too large Load diff