Merge branch 'develop' into PlaybackContainer

This commit is contained in:
Suguru Hirahara 2023-02-25 07:10:38 +00:00 committed by GitHub
commit b51ea6546e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
317 changed files with 5781 additions and 5175 deletions

View file

@ -23,4 +23,4 @@ indent_size = 4
trim_trailing_whitespace = true
[*.{yml,yaml}]
indent_size = 2
indent_size = 4

View file

@ -5,6 +5,9 @@ on:
workflows: ["Element Web - Build"]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
jobs:
prepare:
name: Prepare
@ -162,8 +165,9 @@ jobs:
PERCY_BRANCH: ${{ github.event.workflow_run.head_branch }}
PERCY_COMMIT: ${{ github.event.workflow_run.head_sha }}
PERCY_PULL_REQUEST: ${{ needs.prepare.outputs.pr_id }}
PERCY_PARALLEL_TOTAL: ${{ strategy.job-total }}
PERCY_PARALLEL_NONCE: ${{ needs.prepare.outputs.uuid }}
# We manually finalize the build in the report stage
PERCY_PARALLEL_TOTAL: -1
- name: Upload Artifact
if: failure()
@ -181,14 +185,35 @@ jobs:
with:
name: cypress-junit
path: cypress/results
report:
name: Report results
needs: tests
needs:
- prepare
- tests
runs-on: ubuntu-latest
if: always()
permissions:
statuses: write
steps:
- name: Finalize Percy
if: needs.prepare.outputs.percy_enable == '1'
run: npx -p @percy/cli percy build:finalize
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
PERCY_PARALLEL_NONCE: ${{ needs.prepare.outputs.uuid }}
- name: Skip Percy required check
if: needs.prepare.outputs.percy_enable != '1'
uses: Sibz/github-status-action@v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
description: Percy skipped
context: percy/matrix-react-sdk
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- uses: Sibz/github-status-action@v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
@ -196,6 +221,7 @@ jobs:
context: ${{ github.workflow }} / cypress (${{ github.event.workflow_run.event }} => ${{ github.event_name }})
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
testrail:
name: Report results to testrail
needs:

View file

@ -6,7 +6,5 @@ concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }}
jobs:
action:
uses: matrix-org/matrix-js-sdk/.github/workflows/pull_request.yaml@develop
with:
labels: "T-Defect,T-Enhancement,T-Task"
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

View file

@ -7,6 +7,10 @@ on:
types: [upstream-sdk-notify]
workflow_call:
inputs:
disable_coverage:
type: boolean
required: false
description: "Specify true to skip generating and uploading coverage for tests"
matrix-js-sdk-sha:
type: string
required: false
@ -39,16 +43,21 @@ jobs:
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: Run tests with coverage and metrics
- name: Load metrics reporter
id: metrics
if: github.ref == 'refs/heads/develop'
run: "yarn coverage --ci --reporters github-actions '--reporters=<rootDir>/test/slowReporter.js' --max-workers ${{ steps.cpu-cores.outputs.count }}"
run: |
echo "extra-reporter='--reporters=<rootDir>/test/slowReporter.js'" >> $GITHUB_OUTPUT
- name: Run tests with coverage
if: github.ref != 'refs/heads/develop'
run: "yarn coverage --ci --reporters github-actions --max-workers ${{ steps.cpu-cores.outputs.count }}"
- name: Run tests
run: |
yarn ${{ inputs.disable_coverage != 'true' && 'coverage' || 'test' }} \
--ci \
--reporters github-actions ${{ steps.metrics.outputs.extra-reporter }} \
--max-workers ${{ steps.cpu-cores.outputs.count }}
- name: Upload Artifact
if: inputs.matrix-js-sdk-sha == ''
if: inputs.disable_coverage != 'true'
uses: actions/upload-artifact@v3
with:
name: coverage

View file

@ -118,7 +118,12 @@ describe("Decryption Failure Bar", () => {
"Verify this device to access all messages",
);
cy.percySnapshot("DecryptionFailureBar prompts user to verify");
cy.get(".mx_DecryptionFailureBar").percySnapshotElement(
"DecryptionFailureBar prompts user to verify",
{
widths: [320, 640],
},
);
cy.contains(".mx_DecryptionFailureBar_button", "Resend key requests").should("not.exist");
cy.contains(".mx_DecryptionFailureBar_button", "Verify").click();
@ -146,8 +151,11 @@ describe("Decryption Failure Bar", () => {
"Open another device to load encrypted messages",
);
cy.percySnapshot(
cy.get(".mx_DecryptionFailureBar").percySnapshotElement(
"DecryptionFailureBar prompts user to open another device, with Resend Key Requests button",
{
widths: [320, 640],
},
);
cy.intercept("/_matrix/client/r0/sendToDevice/m.room_key_request/*").as("keyRequest");
@ -155,8 +163,11 @@ describe("Decryption Failure Bar", () => {
cy.wait("@keyRequest");
cy.contains(".mx_DecryptionFailureBar_button", "Resend key requests").should("not.exist");
cy.percySnapshot(
cy.get(".mx_DecryptionFailureBar").percySnapshotElement(
"DecryptionFailureBar prompts user to open another device, " + "without Resend Key Requests button",
{
widths: [320, 640],
},
);
},
);
@ -177,7 +188,9 @@ describe("Decryption Failure Bar", () => {
"Reset your keys to prevent future decryption errors",
);
cy.percySnapshot("DecryptionFailureBar prompts user to reset keys");
cy.get(".mx_DecryptionFailureBar").percySnapshotElement("DecryptionFailureBar prompts user to reset keys", {
widths: [320, 640],
});
cy.contains(".mx_DecryptionFailureBar_button", "Reset").click();
@ -196,7 +209,12 @@ describe("Decryption Failure Bar", () => {
"Some messages could not be decrypted",
);
cy.percySnapshot("DecryptionFailureBar displays general message with no call to action");
cy.get(".mx_DecryptionFailureBar").percySnapshotElement(
"DecryptionFailureBar displays general message with no call to action",
{
widths: [320, 640],
},
);
},
);
@ -210,7 +228,10 @@ describe("Decryption Failure Bar", () => {
cy.get(".mx_DecryptionFailureBar").should("exist");
cy.get(".mx_DecryptionFailureBar .mx_Spinner").should("exist");
cy.percySnapshot("DecryptionFailureBar displays loading spinner");
cy.get(".mx_DecryptionFailureBar").percySnapshotElement("DecryptionFailureBar displays loading spinner", {
allowSpinners: true,
widths: [320, 640],
});
cy.wait(5000);
cy.get(".mx_DecryptionFailureBar .mx_Spinner").should("not.exist");

View file

@ -27,7 +27,7 @@ describe("Location sharing", () => {
};
const submitShareLocation = (): void => {
cy.get('[data-test-id="location-picker-submit-button"]').click();
cy.get('[data-testid="location-picker-submit-button"]').click();
};
beforeEach(() => {

View file

@ -159,8 +159,8 @@ describe("Timeline", () => {
".mx_GenericEventListSummary_summary",
"created and configured the room.",
).should("exist");
cy.get(".mx_Spinner").should("not.exist");
cy.percySnapshot("Configured room on IRC layout");
cy.get(".mx_MainSplit").percySnapshotElement("Configured room on IRC layout");
});
it("should add inline start margin to an event line on IRC layout", () => {
@ -185,11 +185,12 @@ describe("Timeline", () => {
.should("have.css", "margin-inline-start", "104px")
.should("have.css", "inset-inline-start", "0px");
cy.get(".mx_Spinner").should("not.exist");
// Exclude timestamp from snapshot
const percyCSS =
".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp " + "{ visibility: hidden !important; }";
cy.percySnapshot("Event line with inline start margin on IRC layout", { percyCSS });
".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp { visibility: hidden !important; }";
cy.get(".mx_MainSplit").percySnapshotElement("Event line with inline start margin on IRC layout", {
percyCSS,
});
cy.checkA11y();
});
@ -213,8 +214,7 @@ describe("Timeline", () => {
cy.get(".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp").click();
// Exclude timestamp from snapshot
const percyCSS =
".mx_RoomView_body .mx_EventTile .mx_MessageTimestamp " + "{ visibility: hidden !important; }";
const percyCSS = ".mx_RoomView_body .mx_EventTile .mx_MessageTimestamp { visibility: hidden !important; }";
// should not add inline start padding to a hidden event line on IRC layout
cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
@ -223,14 +223,20 @@ describe("Timeline", () => {
"padding-inline-start",
"0px",
);
cy.percySnapshot("Hidden event line with zero padding on IRC layout", { percyCSS });
cy.get(".mx_MainSplit").percySnapshotElement("Hidden event line with zero padding on IRC layout", {
percyCSS,
});
// should add inline start padding to a hidden event line on modern layout
cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group);
cy.get(".mx_EventTile[data-layout=group].mx_EventTile_info .mx_EventTile_line")
// calc(var(--EventTile_group_line-spacing-inline-start) + 20px) = 64 + 20 = 84px
.should("have.css", "padding-inline-start", "84px");
cy.percySnapshot("Hidden event line with padding on modern layout", { percyCSS });
cy.get(".mx_MainSplit").percySnapshotElement("Hidden event line with padding on modern layout", {
percyCSS,
});
});
it("should click top left of view source event toggle", () => {
@ -329,7 +335,12 @@ describe("Timeline", () => {
cy.wait("@mxc");
cy.checkA11y();
// Exclude timestamp from snapshot
const percyCSS =
".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp { visibility: hidden !important; }";
cy.get(".mx_EventTile_last").percySnapshotElement("URL Preview", {
percyCSS,
widths: [800, 400],
});
});

View file

@ -133,6 +133,7 @@ describe("Stickers", () => {
type: "m.stickerpicker",
name: STICKER_PICKER_WIDGET_NAME,
url: stickerPickerUrl,
creatorUserId: "@userId",
},
id: STICKER_PICKER_WIDGET_ID,
},

View file

@ -22,6 +22,7 @@ declare global {
namespace Cypress {
interface SnapshotOptions extends PercySnapshotOptions {
domTransformation?: (documentClone: Document) => void;
allowSpinners?: boolean;
}
interface Chainable {
@ -38,6 +39,10 @@ declare global {
}
Cypress.Commands.add("percySnapshotElement", { prevSubject: "element" }, (subject, name, options) => {
if (!options?.allowSpinners) {
// Await spinners to vanish
cy.get(".mx_Spinner").should("not.exist");
}
cy.percySnapshot(name, {
domTransformation: (documentClone) => scope(documentClone, subject.selector),
...options,

View file

@ -58,7 +58,7 @@
"@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.4.0",
"@matrix-org/matrix-wysiwyg": "^1.1.1",
"@matrix-org/react-sdk-module-api": "^0.0.3",
"@matrix-org/react-sdk-module-api": "^0.0.4",
"@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0",
"@testing-library/react-hooks": "^8.0.1",
@ -92,7 +92,7 @@
"lodash": "^4.17.20",
"maplibre-gl": "^2.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
"matrix-events-sdk": "2.0.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^1.1.1",
"minimist": "^1.2.5",
@ -111,7 +111,7 @@
"react-transition-group": "^4.4.1",
"rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "^2.3.2",
"sanitize-html": "2.8.0",
"tar-js": "^0.3.0",
"ua-parser-js": "^1.0.2",
"url": "^0.11.0",
@ -167,9 +167,8 @@
"@types/react": "17.0.49",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "17.0.17",
"@types/react-test-renderer": "^17.0.1",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^2.3.1",
"@types/sanitize-html": "2.8.0",
"@types/tar-js": "^0.3.2",
"@types/ua-parser-js": "^0.7.36",
"@types/zxcvbn": "^4.4.0",
@ -212,7 +211,6 @@
"postcss-scss": "^4.0.4",
"prettier": "2.8.0",
"raw-loader": "^4.0.2",
"react-test-renderer": "^17.0.2",
"rimraf": "^3.0.2",
"stylelint": "^14.9.1",
"stylelint-config-prettier": "^9.0.4",

View file

@ -18,6 +18,7 @@
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
@import "./components/views/context_menus/_KebabContextMenu.pcss";
@import "./components/views/dialogs/polls/_PollListItem.pcss";
@import "./components/views/dialogs/polls/_PollListItemEnded.pcss";
@import "./components/views/elements/_FilterDropdown.pcss";
@import "./components/views/elements/_FilterTabGroup.pcss";
@import "./components/views/elements/_LearnMore.pcss";

View file

@ -0,0 +1,60 @@
/*
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.
*/
.mx_PollListItemEnded {
width: 100%;
display: flex;
flex-direction: column;
color: $primary-content;
}
.mx_PollListItemEnded_title {
display: grid;
justify-content: left;
align-items: center;
grid-gap: $spacing-8;
grid-template-columns: min-content 1fr min-content;
grid-template-rows: auto;
}
.mx_PollListItemEnded_icon {
height: 14px;
width: 14px;
color: $quaternary-content;
padding-left: $spacing-8;
}
.mx_PollListItemEnded_date {
font-size: $font-12px;
color: $secondary-content;
}
.mx_PollListItemEnded_question {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mx_PollListItemEnded_answers {
display: grid;
grid-gap: $spacing-8;
margin-top: $spacing-12;
}
.mx_PollListItemEnded_voteCount {
// 6px to match PollOption padding
margin: $spacing-8 0 0 6px;
}

View file

@ -18,7 +18,6 @@ limitations under the License.
border: 1px solid $quinary-content;
border-radius: 8px;
padding: 6px 12px;
max-width: 550px;
background-color: $background;
.mx_StyledRadioButton_content,

View file

@ -32,6 +32,10 @@ limitations under the License.
grid-gap: $spacing-20;
padding-right: $spacing-64;
margin: $spacing-32 0;
&.mx_PollHistoryList_list_ENDED {
grid-gap: $spacing-32;
}
}
.mx_PollHistoryList_noResults {
@ -42,3 +46,14 @@ limitations under the License.
justify-content: center;
color: $secondary-content;
}
.mx_PollHistoryList_loading {
color: $secondary-content;
text-align: center;
// center in all free space
// when there are no results
&.mx_PollHistoryList_noResultsYet {
margin: auto auto;
}
}

View file

@ -23,11 +23,12 @@ limitations under the License.
max-width: 100%;
&.mx_CopyableText_border {
overflow: auto;
border-radius: 5px;
border: solid 1px $light-fg-color;
margin-bottom: 10px;
margin-top: 10px;
padding: 10px;
padding: 10px 0 10px 10px;
}
.mx_CopyableText_copyButton {
@ -36,11 +37,15 @@ limitations under the License.
width: 1em;
height: 1em;
cursor: pointer;
margin-left: 20px;
padding-left: 12px;
padding-right: 10px;
display: block;
/* If the copy button is used within a scrollable div, make it stick to the right while scrolling */
position: sticky;
right: 0;
/* center to first line */
position: relative;
top: 0.15em;
background-color: $background;
&::before {
content: "";

View file

@ -160,6 +160,7 @@ limitations under the License.
flex-wrap: wrap;
align-items: center;
color: $secondary-content;
font-size: $font-12px;
gap: $spacing-12; /* See mx_IncomingLegacyCallToast_buttons */
margin-inline-start: 42px; /* avatar (32px) + mx_LegacyCallEvent_info_basic margin (10px) */
word-break: break-word;
@ -168,6 +169,7 @@ limitations under the License.
.mx_LegacyCallEvent_content_button {
@mixin LegacyCallButton;
padding: 0 $spacing-12;
font-size: inherit;
span::before {
mask-size: 16px;

View file

@ -70,4 +70,5 @@ limitations under the License.
display: grid;
grid-gap: $spacing-16;
margin-bottom: $spacing-8;
max-width: 550px;
}

View file

@ -49,7 +49,7 @@ export type Binding = {
*/
export default class AddThreepid {
private sessionId: string;
private submitUrl: string;
private submitUrl?: string;
private clientSecret: string;
private bind: boolean;
@ -93,7 +93,7 @@ export default class AddThreepid {
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
// For separate bind, request a token directly from the IS.
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
const identityAccessToken = (await authClient.getAccessToken()) ?? undefined;
return MatrixClientPeg.get()
.requestEmailToken(emailAddress, this.clientSecret, 1, undefined, identityAccessToken)
.then(
@ -155,7 +155,7 @@ export default class AddThreepid {
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
// For separate bind, request a token directly from the IS.
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
const identityAccessToken = (await authClient.getAccessToken()) ?? undefined;
return MatrixClientPeg.get()
.requestMsisdnToken(phoneCountry, phoneNumber, this.clientSecret, 1, undefined, identityAccessToken)
.then(
@ -184,7 +184,7 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async checkEmailLinkClicked(): Promise<[boolean, IAuthData | Error | null]> {
public async checkEmailLinkClicked(): Promise<[boolean, IAuthData | Error | null] | undefined> {
try {
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
if (this.bind) {
@ -282,7 +282,7 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async haveMsisdnToken(msisdnToken: string): Promise<any[]> {
public async haveMsisdnToken(msisdnToken: string): Promise<any[] | undefined> {
const authClient = new IdentityAuthClient();
const supportsSeparateAddAndBind = await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind();

View file

@ -546,7 +546,7 @@ export default class ContentMessages {
if (upload.cancelled) throw new UploadCanceledError();
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
const response = await matrixClient.sendMessage(roomId, threadId, content);
const response = await matrixClient.sendMessage(roomId, threadId ?? null, content);
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
sendRoundTripMetric(matrixClient, roomId, response.event_id);

View file

@ -1024,13 +1024,12 @@ export default class LegacyCallHandler extends EventEmitter {
}
public answerCall(roomId: string): void {
const call = this.calls.get(roomId);
this.stopRingingIfPossible(call.callId);
// no call to answer
if (!this.calls.has(roomId)) return;
const call = this.calls.get(roomId)!;
this.stopRingingIfPossible(call.callId);
if (this.getAllActiveCalls().length > 1) {
Modal.createDialog(ErrorDialog, {
title: _t("Too Many Calls"),

View file

@ -287,7 +287,7 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise<void> {
return MatrixClientPeg.get().store.deleteAllData();
})
.then(() => {
PlatformPeg.get().reload();
PlatformPeg.get()?.reload();
});
}
}
@ -519,7 +519,7 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<Matr
stopMatrixClient();
const pickleKey =
credentials.userId && credentials.deviceId
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
? await PlatformPeg.get()?.createPickleKey(credentials.userId, credentials.deviceId)
: null;
if (pickleKey) {

View file

@ -166,7 +166,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
}
try {
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time"), 10);
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10);
const diff = Date.now() - registrationTime;
return diff / 36e5 <= hours;
} catch (e) {
@ -176,7 +176,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
public userRegisteredAfter(timestamp: Date): boolean {
try {
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time"), 10);
const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time")!, 10);
return timestamp.getTime() <= registrationTime;
} catch (e) {
return false;
@ -292,7 +292,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
}
public getCredentials(): IMatrixClientCreds {
let copiedCredentials = this.currentClientCreds;
let copiedCredentials: IMatrixClientCreds | null = this.currentClientCreds;
if (this.currentClientCreds?.userId !== this.matrixClient?.credentials?.userId) {
// cached credentials belong to a different user - don't use them
copiedCredentials = null;

View file

@ -64,7 +64,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*
* @return Promise<IMediaDevices> The available media devices
*/
public static async getDevices(): Promise<IMediaDevices> {
public static async getDevices(): Promise<IMediaDevices | undefined> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const output: Record<MediaDeviceKindEnum, MediaDeviceInfo[]> = {

View file

@ -54,8 +54,8 @@ export default class PosthogTrackers {
}
private view: Views = Views.LOADING;
private pageType?: PageType = null;
private override?: ScreenName = null;
private pageType?: PageType;
private override?: ScreenName;
public trackPageChange(view: Views, pageType: PageType | undefined, durationMs: number): void {
this.view = view;
@ -66,7 +66,7 @@ export default class PosthogTrackers {
private trackPage(durationMs?: number): void {
const screenName =
this.view === Views.LOGGED_IN ? loggedInPageTypeMap[this.pageType] : notLoggedInMap[this.view];
this.view === Views.LOGGED_IN ? loggedInPageTypeMap[this.pageType!] : notLoggedInMap[this.view];
PosthogAnalytics.instance.trackEvent<ScreenEvent>({
eventName: "$pageview",
$current_url: screenName,
@ -85,7 +85,7 @@ export default class PosthogTrackers {
public clearOverride(screenName: ScreenName): void {
if (screenName !== this.override) return;
this.override = null;
this.override = undefined;
this.trackPage();
}

View file

@ -26,7 +26,6 @@ export const DEFAULTS: IConfigOptions = {
brand: "Element",
integrations_ui_url: "https://scalar.vector.im/",
integrations_rest_url: "https://scalar.vector.im/api",
bug_report_endpoint_url: null,
uisi_autorageshake_app: "element-auto-uisi",
jitsi: {

View file

@ -198,7 +198,7 @@ function reject(error?: any): RunResult {
return { error };
}
function success(promise?: Promise<any>): RunResult {
function success(promise: Promise<any> = Promise.resolve()): RunResult {
return { promise };
}
@ -221,7 +221,7 @@ export const Commands = [
command: "spoiler",
args: "<message>",
description: _td("Sends the given message as a spoiler"),
runFn: function (roomId, message) {
runFn: function (roomId, message = "") {
return successSync(ContentHelpers.makeHtmlMessage(message, `<span data-mx-spoiler>${message}</span>`));
},
category: CommandCategories.messages,
@ -282,7 +282,7 @@ export const Commands = [
command: "plain",
args: "<message>",
description: _td("Sends a message as plain text, without interpreting it as markdown"),
runFn: function (roomId, messages) {
runFn: function (roomId, messages = "") {
return successSync(ContentHelpers.makeTextMessage(messages));
},
category: CommandCategories.messages,
@ -291,7 +291,7 @@ export const Commands = [
command: "html",
args: "<message>",
description: _td("Sends a message as html, without interpreting it as markdown"),
runFn: function (roomId, messages) {
runFn: function (roomId, messages = "") {
return successSync(ContentHelpers.makeHtmlMessage(messages, messages));
},
category: CommandCategories.messages,

View file

@ -27,6 +27,7 @@ import { timeout } from "../utils/promise";
import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
import SpaceProvider from "./SpaceProvider";
import { TimelineRenderingType } from "../contexts/RoomContext";
import { filterBoolean } from "../utils/arrays";
export interface ISelectionRange {
beginning?: boolean; // whether the selection is in the first block of the editor or not
@ -55,7 +56,7 @@ const PROVIDER_COMPLETION_TIMEOUT = 3000;
export interface IProviderCompletions {
completions: ICompletion[];
provider: AutocompleteProvider;
command: ICommand;
command: Partial<ICommand>;
}
export default class Autocompleter {
@ -98,8 +99,8 @@ export default class Autocompleter {
);
// map then filter to maintain the index for the map-operation, for this.providers to line up
return completionsList
.map((completions, i) => {
return filterBoolean(
completionsList.map((completions, i) => {
if (!completions || !completions.length) return;
return {
@ -112,7 +113,7 @@ export default class Autocompleter {
*/
command: this.providers[i].getCurrentCommand(query, selection, force),
};
})
.filter(Boolean) as IProviderCompletions[];
}),
);
}
}

View file

@ -32,6 +32,7 @@ import SettingsStore from "../settings/SettingsStore";
import { EMOJI, IEmoji, getEmojiFromUnicode } from "../emoji";
import { TimelineRenderingType } from "../contexts/RoomContext";
import * as recent from "../emojipicker/recent";
import { filterBoolean } from "../utils/arrays";
const LIMIT = 20;
@ -94,7 +95,7 @@ export default class EmojiProvider extends AutocompleteProvider {
shouldMatchWordsOnly: true,
});
this.recentlyUsed = Array.from(new Set(recent.get().map(getEmojiFromUnicode).filter(Boolean))) as IEmoji[];
this.recentlyUsed = Array.from(new Set(filterBoolean(recent.get().map(getEmojiFromUnicode))));
}
public async getCompletions(

View file

@ -46,7 +46,7 @@ interface IState {
export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
public static contextType = MatrixClientContext;
private unmounted = false;
private dispatcherRef: string = null;
private dispatcherRef: string | null = null;
public constructor(props: IProps, context: typeof MatrixClientContext) {
super(props, context);
@ -64,7 +64,7 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
let res: Response;
try {
res = await fetch(this.props.url, { method: "GET" });
res = await fetch(this.props.url!, { method: "GET" });
} catch (err) {
if (this.unmounted) return;
logger.warn(`Error loading page: ${err}`);
@ -84,7 +84,7 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
if (this.props.replaceMap) {
Object.keys(this.props.replaceMap).forEach((key) => {
body = body.split(key).join(this.props.replaceMap[key]);
body = body.split(key).join(this.props.replaceMap![key]);
});
}
@ -123,8 +123,7 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
const client = this.context || MatrixClientPeg.get();
const isGuest = client ? client.isGuest() : true;
const className = this.props.className;
const classes = classnames({
[className]: true,
const classes = classnames(className, {
[`${className}_guest`]: isGuest,
[`${className}_loggedIn`]: !!client,
});

View file

@ -40,6 +40,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
const onDragEnter = (ev: DragEvent): void => {
ev.stopPropagation();
ev.preventDefault();
if (!ev.dataTransfer) return;
setState((state) => ({
// We always increment the counter no matter the types, because dragging is
@ -49,7 +50,8 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
// https://docs.w3cub.com/dom/datatransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
dragging:
ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")
ev.dataTransfer!.types.includes("Files") ||
ev.dataTransfer!.types.includes("application/x-moz-file")
? true
: state.dragging,
}));
@ -68,6 +70,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
const onDragOver = (ev: DragEvent): void => {
ev.stopPropagation();
ev.preventDefault();
if (!ev.dataTransfer) return;
ev.dataTransfer.dropEffect = "none";
@ -82,6 +85,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
const onDrop = (ev: DragEvent): void => {
ev.stopPropagation();
ev.preventDefault();
if (!ev.dataTransfer) return;
onFileDrop(ev.dataTransfer);
setState((state) => ({

View file

@ -66,7 +66,7 @@ class FilePanel extends React.Component<IProps, IState> {
private onRoomTimeline = (
ev: MatrixEvent,
room: Room | null,
room: Room | undefined,
toStartOfTimeline: boolean,
removed: boolean,
data: IRoomTimelineData,
@ -78,7 +78,7 @@ class FilePanel extends React.Component<IProps, IState> {
client.decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted()) {
this.decryptingEvents.add(ev.getId());
this.decryptingEvents.add(ev.getId()!);
} else {
this.addEncryptedLiveEvent(ev);
}
@ -86,7 +86,7 @@ class FilePanel extends React.Component<IProps, IState> {
private onEventDecrypted = (ev: MatrixEvent, err?: any): void => {
if (ev.getRoomId() !== this.props.roomId) return;
const eventId = ev.getId();
const eventId = ev.getId()!;
if (!this.decryptingEvents.delete(eventId)) return;
if (err) return;
@ -103,7 +103,7 @@ class FilePanel extends React.Component<IProps, IState> {
return;
}
if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) {
if (!this.state.timelineSet.eventIdToTimeline(ev.getId()!)) {
this.state.timelineSet.addEventToTimeline(ev, timeline, false);
}
}

View file

@ -56,15 +56,15 @@ const getOwnProfile = (
userId: string,
): {
displayName: string;
avatarUrl: string;
avatarUrl?: string;
} => ({
displayName: OwnProfileStore.instance.displayName || userId,
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE),
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE) ?? undefined,
});
const UserWelcomeTop: React.FC = () => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
const userId = cli.getUserId()!;
const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId));
useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => {
setOwnProfile(getOwnProfile(userId));

View file

@ -33,7 +33,7 @@ export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
type InteractiveAuthCallbackSuccess = (
success: true,
response: IAuthData,
response?: IAuthData,
extra?: { emailSid?: string; clientSecret?: string },
) => void;
type InteractiveAuthCallbackFailure = (success: false, response: IAuthData | Error) => void;
@ -94,7 +94,7 @@ interface IState {
export default class InteractiveAuthComponent extends React.Component<IProps, IState> {
private readonly authLogic: InteractiveAuth;
private readonly intervalId: number = null;
private readonly intervalId: number | null = null;
private readonly stageComponent = createRef<IStageComponent>();
private unmounted = false;
@ -103,10 +103,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
super(props);
this.state = {
authStage: null,
busy: false,
errorText: null,
errorCode: null,
submitButtonEnabled: false,
};
@ -213,8 +210,8 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
if (busy) {
this.setState({
busy: true,
errorText: null,
errorCode: null,
errorText: undefined,
errorCode: undefined,
});
}
// The JS SDK eagerly reports itself as "not busy" right after any

View file

@ -166,10 +166,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
>();
let lastTopHeader;
let firstBottomHeader;
let lastTopHeader: HTMLDivElement | undefined;
let firstBottomHeader: HTMLDivElement | undefined;
for (const sublist of sublists) {
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist_stickable");
if (!header) continue; // this should never occur
header.style.removeProperty("display"); // always clear display:none first
// When an element is <=40% off screen, make it take over
@ -196,7 +197,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// cause a no-op update, as adding/removing properties that are/aren't there cause
// layout updates.
for (const header of targetStyles.keys()) {
const style = targetStyles.get(header);
const style = targetStyles.get(header)!;
if (style.makeInvisible) {
// we will have already removed the 'display: none', so add it back.
@ -324,7 +325,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
private renderSearchDialExplore(): React.ReactNode {
let dialPadButton = null;
let dialPadButton: JSX.Element | undefined;
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
@ -338,7 +339,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
);
}
let rightButton: JSX.Element;
let rightButton: JSX.Element | undefined;
if (this.state.showBreadcrumbs === BreadcrumbsMode.Labs) {
rightButton = <RecentlyViewedButton />;
} else if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) {

View file

@ -35,7 +35,7 @@ const CONNECTING_STATES = [
CallState.CreateAnswer,
];
const SUPPORTED_STATES = [CallState.Connected, CallState.Ringing];
const SUPPORTED_STATES = [CallState.Connected, CallState.Ringing, CallState.Ended];
export enum CustomCallState {
Missed = "missed",
@ -72,7 +72,7 @@ export function buildLegacyCallEventGroupers(
export default class LegacyCallEventGrouper extends EventEmitter {
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
private call: MatrixCall;
private call: MatrixCall | null = null;
public state: CallState | CustomCallState;
public constructor() {
@ -111,7 +111,7 @@ export default class LegacyCallEventGrouper extends EventEmitter {
}
public get hangupReason(): string | null {
return this.hangup?.getContent()?.reason;
return this.call?.hangupReason ?? this.hangup?.getContent()?.reason ?? null;
}
public get rejectParty(): string {

View file

@ -226,8 +226,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private screenAfterLogin?: IScreen;
private tokenLogin?: boolean;
private accountPassword?: string;
private accountPasswordTimer?: number;
private focusComposer: boolean;
private subTitleStatus: string;
private prevWindowWidth: number;
@ -296,9 +294,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Lifecycle.loadSession();
}
this.accountPassword = null;
this.accountPasswordTimer = null;
this.dispatcherRef = dis.register(this.onAction);
this.themeWatcher = new ThemeWatcher();
@ -439,7 +434,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
window.removeEventListener("resize", this.onWindowResized);
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
this.stores.accountPasswordStore.clearPassword();
if (this.voiceBroadcastResumer) this.voiceBroadcastResumer.destroy();
}
@ -1987,13 +1982,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* this, as they instead jump straight into the app after `attemptTokenLogin`.
*/
private onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string): Promise<void> => {
this.accountPassword = password;
// self-destruct the password after 5mins
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
this.accountPasswordTimer = window.setTimeout(() => {
this.accountPassword = null;
this.accountPasswordTimer = null;
}, 60 * 5 * 1000);
this.stores.accountPasswordStore.setPassword(password);
// Create and start the client
await Lifecycle.setLoggedIn(credentials);
@ -2037,7 +2026,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
view = (
<E2eSetup
onFinished={this.onCompleteSecurityE2eSetupFinished}
accountPassword={this.accountPassword}
accountPassword={this.stores.accountPasswordStore.getPassword()}
tokenLogin={!!this.tokenLogin}
/>
);

View file

@ -114,8 +114,11 @@ import { RoomSearchView } from "./RoomSearchView";
import eventSearch from "../../Searching";
import VoipUserMapper from "../../VoipUserMapper";
import { isCallEvent } from "./LegacyCallEventGrouper";
import { WidgetType } from "../../widgets/WidgetType";
import WidgetUtils from "../../utils/WidgetUtils";
const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
let debuglog = function (msg: string): void {};
const BROWSER_SUPPORTS_SANDBOX = "sandbox" in document.createElement("iframe");
@ -483,6 +486,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private onWidgetStoreUpdate = (): void => {
if (!this.state.room) return;
this.checkWidgets(this.state.room);
this.doMaybeRemoveOwnJitsiWidget();
};
private onWidgetEchoStoreUpdate = (): void => {
@ -503,6 +507,56 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.checkWidgets(this.state.room);
};
/**
* Removes the Jitsi widget from the current user if
* - Multiple Jitsi widgets have been added within {@link PREVENT_MULTIPLE_JITSI_WITHIN}
* - The last (server timestamp) of these widgets is from the currrent user
* This solves the issue if some people decide to start a conference and click the call button at the same time.
*/
private doMaybeRemoveOwnJitsiWidget(): void {
if (!this.state.roomId || !this.state.room || !this.context.client) return;
const apps = this.context.widgetStore.getApps(this.state.roomId);
const jitsiApps = apps.filter((app) => app.eventId && WidgetType.JITSI.matches(app.type));
// less than two Jitsi widgets → nothing to do
if (jitsiApps.length < 2) return;
const currentUserId = this.context.client.getSafeUserId();
const createdByCurrentUser = jitsiApps.find((apps) => apps.creatorUserId === currentUserId);
// no Jitsi widget from current user → nothing to do
if (!createdByCurrentUser) return;
const createdByCurrentUserEvent = this.state.room.findEventById(createdByCurrentUser.eventId!);
// widget event not found → nothing can be done
if (!createdByCurrentUserEvent) return;
const createdByCurrentUserTs = createdByCurrentUserEvent.getTs();
// widget timestamp is empty → nothing can be done
if (!createdByCurrentUserTs) return;
const lastCreatedByOtherTs = jitsiApps.reduce((maxByNow: number, app) => {
if (app.eventId === createdByCurrentUser.eventId) return maxByNow;
const appCreateTs = this.state.room!.findEventById(app.eventId!)?.getTs() || 0;
return Math.max(maxByNow, appCreateTs);
}, 0);
// last widget timestamp from other is empty → nothing can be done
if (!lastCreatedByOtherTs) return;
if (
createdByCurrentUserTs > lastCreatedByOtherTs &&
createdByCurrentUserTs - lastCreatedByOtherTs < PREVENT_MULTIPLE_JITSI_WITHIN
) {
// more than one Jitsi widget with the last one from the current user → remove it
WidgetUtils.setRoomWidget(this.state.roomId, createdByCurrentUser.id);
}
}
private checkWidgets = (room: Room): void => {
this.setState({
hasPinnedWidgets: this.context.widgetLayoutStore.hasPinnedWidgets(room),
@ -1903,6 +1957,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
loading={loading}
joining={this.state.joining}
oobData={this.props.oobData}
roomId={this.state.roomId}
/>
</ErrorBoundary>
</div>
@ -1932,7 +1987,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
invitedEmail={invitedEmail}
oobData={this.props.oobData}
signUrl={this.props.threepidInvite?.signUrl}
room={this.state.room}
roomId={this.state.roomId}
/>
</ErrorBoundary>
</div>
@ -1969,6 +2024,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
error={this.state.roomLoadError}
joining={this.state.joining}
rejecting={this.state.rejecting}
roomId={this.state.roomId}
/>
</ErrorBoundary>
);
@ -1998,6 +2054,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
canPreview={false}
joining={this.state.joining}
room={this.state.room}
roomId={this.state.roomId}
/>
</ErrorBoundary>
</div>
@ -2090,6 +2147,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
oobData={this.props.oobData}
canPreview={this.state.canPeek}
room={this.state.room}
roomId={this.state.roomId}
/>
);
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) {

View file

@ -139,15 +139,15 @@ export default class ViewSource extends React.Component<IProps, IState> {
private canSendStateEvent(mxEvent: MatrixEvent): boolean {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(mxEvent.getRoomId());
return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
return !!room?.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
}
public render(): React.ReactNode {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEditing = this.state.isEditing;
const roomId = mxEvent.getRoomId();
const eventId = mxEvent.getId();
const roomId = mxEvent.getRoomId()!;
const eventId = mxEvent.getId()!;
const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent);
return (
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("View Source")}>

View file

@ -39,6 +39,7 @@ import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { filterBoolean } from "../../../utils/arrays";
// These are used in several places, and come from the js-sdk's autodiscovery
// stuff. We define them here so that they'll be picked up by i18n.
@ -120,15 +121,11 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
this.state = {
busy: false,
busyLoggingIn: null,
errorText: null,
loginIncorrect: false,
canTryLogin: true,
flows: null,
username: props.defaultUsername ? props.defaultUsername : "",
phoneCountry: null,
phoneNumber: "",
serverIsAlive: true,
@ -167,7 +164,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
}
public isBusy = (): boolean => this.state.busy || this.props.busy;
public isBusy = (): boolean => !!this.state.busy || !!this.props.busy;
public onPasswordLogin: OnPasswordLogin = async (
username: string | undefined,
@ -349,7 +346,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
ev.preventDefault();
ev.stopPropagation();
const ssoKind = ssoFlow.type === "m.login.sso" ? "sso" : "cas";
PlatformPeg.get().startSingleSignOn(
PlatformPeg.get()?.startSingleSignOn(
this.loginLogic.createTemporaryClient(),
ssoKind,
this.props.fragmentAfterLogin,
@ -511,13 +508,13 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
return errorText;
}
public renderLoginComponentForFlows(): JSX.Element {
public renderLoginComponentForFlows(): ReactNode {
if (!this.state.flows) return null;
// this is the ideal order we want to show the flows in
const order = ["m.login.password", "m.login.sso"];
const flows = order.map((type) => this.state.flows.find((flow) => flow.type === type)).filter(Boolean);
const flows = filterBoolean(order.map((type) => this.state.flows.find((flow) => flow.type === type)));
return (
<React.Fragment>
{flows.map((flow) => {

View file

@ -65,7 +65,7 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
"src",
`https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit`,
);
this.recaptchaContainer.current.appendChild(scriptTag);
this.recaptchaContainer.current?.appendChild(scriptTag);
}
}
@ -95,7 +95,7 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
}
logger.info("Rendering to %s", divId);
this.captchaWidgetId = global.grecaptcha.render(divId, {
this.captchaWidgetId = global.grecaptcha?.render(divId, {
sitekey: publicKey,
callback: this.props.onCaptchaResponse,
});
@ -113,7 +113,7 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
this.renderRecaptcha(DIV_ID);
// clear error if re-rendered
this.setState({
errorText: null,
errorText: undefined,
});
} catch (e) {
this.setState({
@ -123,7 +123,7 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
}
public render(): React.ReactNode {
let error = null;
let error: JSX.Element | undefined;
if (this.state.errorText) {
error = <div className="error">{this.state.errorText}</div>;
}

View file

@ -50,12 +50,12 @@ class EmailField extends PureComponent<IProps> {
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t(this.props.labelRequired),
invalid: () => _t(this.props.labelRequired!),
},
{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t(this.props.labelInvalid),
invalid: () => _t(this.props.labelInvalid!),
},
],
});
@ -80,7 +80,7 @@ class EmailField extends PureComponent<IProps> {
id={this.props.id}
ref={this.props.fieldRef}
type="text"
label={_t(this.props.label)}
label={_t(this.props.label!)}
value={this.props.value}
autoFocus={this.props.autoFocus}
onChange={this.props.onChange}

View file

@ -36,7 +36,7 @@ interface IProps {
phoneNumber: string;
serverConfig: ValidatedServerConfig;
loginIncorrect?: boolean;
loginIncorrect: boolean;
disableSubmit?: boolean;
busy?: boolean;
@ -67,9 +67,9 @@ const enum LoginField {
* The email/username/phone fields are fully-controlled, the password field is not.
*/
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
private [LoginField.Email]: Field;
private [LoginField.Phone]: Field;
private [LoginField.MatrixId]: Field;
private [LoginField.Email]: Field | null;
private [LoginField.Phone]: Field | null;
private [LoginField.MatrixId]: Field | null;
public static defaultProps = {
onUsernameChanged: function () {},
@ -93,7 +93,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
private onForgotPasswordClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
this.props.onForgotPasswordClick?.();
};
private onSubmitForm = async (ev: SyntheticEvent): Promise<void> => {
@ -116,25 +116,25 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
};
private onUsernameChanged = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.props.onUsernameChanged(ev.target.value);
this.props.onUsernameChanged?.(ev.target.value);
};
private onUsernameBlur = (ev: React.FocusEvent<HTMLInputElement>): void => {
this.props.onUsernameBlur(ev.target.value);
this.props.onUsernameBlur?.(ev.target.value);
};
private onLoginTypeChange = (ev: React.ChangeEvent<HTMLSelectElement>): void => {
const loginType = ev.target.value as IState["loginType"];
this.setState({ loginType });
this.props.onUsernameChanged(""); // Reset because email and username use the same state
this.props.onUsernameChanged?.(""); // Reset because email and username use the same state
};
private onPhoneCountryChanged = (country: PhoneNumberCountryDefinition): void => {
this.props.onPhoneCountryChanged(country.iso2);
this.props.onPhoneCountryChanged?.(country.iso2);
};
private onPhoneNumberChanged = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.props.onPhoneNumberChanged(ev.target.value);
this.props.onPhoneNumberChanged?.(ev.target.value);
};
private onPasswordChanged = (ev: React.ChangeEvent<HTMLInputElement>): void => {
@ -199,7 +199,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return null;
}
private markFieldValid(fieldID: LoginField, valid: boolean): void {
private markFieldValid(fieldID: LoginField, valid?: boolean): void {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
@ -368,7 +368,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
}
public render(): React.ReactNode {
let forgotPasswordJsx;
let forgotPasswordJsx: JSX.Element | undefined;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = (

View file

@ -34,7 +34,7 @@ interface IProps {}
export default class Welcome extends React.PureComponent<IProps> {
public render(): React.ReactNode {
const pagesConfig = SdkConfig.getObject("embedded_pages");
let pageUrl = null;
let pageUrl!: string;
if (pagesConfig) {
pageUrl = pagesConfig.get("welcome_url");
}

View file

@ -158,12 +158,12 @@ const BeaconViewDialog: React.FC<IProps> = ({ initialFocusedBeacon, roomId, matr
)}
{mapDisplayError && <MapError error={mapDisplayError.message as LocationShareError} isMinimised />}
{!centerGeoUri && !mapDisplayError && (
<MapFallback data-test-id="beacon-view-dialog-map-fallback" className="mx_BeaconViewDialog_map">
<MapFallback data-testid="beacon-view-dialog-map-fallback" className="mx_BeaconViewDialog_map">
<span className="mx_BeaconViewDialog_mapFallbackMessage">{_t("No live locations")}</span>
<AccessibleButton
kind="primary"
onClick={onFinished}
data-test-id="beacon-view-dialog-fallback-close"
data-testid="beacon-view-dialog-fallback-close"
>
{_t("Close")}
</AccessibleButton>
@ -179,7 +179,7 @@ const BeaconViewDialog: React.FC<IProps> = ({ initialFocusedBeacon, roomId, matr
<AccessibleButton
kind="primary"
onClick={() => setSidebarOpen(true)}
data-test-id="beacon-view-dialog-open-sidebar"
data-testid="beacon-view-dialog-open-sidebar"
className="mx_BeaconViewDialog_viewListButton"
>
<LiveLocationIcon height={12} />

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { useContext } from "react";
import { MatrixCapabilities } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { ChevronFace } from "../../structures/ContextMenu";
@ -34,6 +35,8 @@ import { WidgetType } from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream";
import { ModuleRunner } from "../../../modules/ModuleRunner";
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
app: IApp;
@ -45,7 +48,7 @@ interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
onEditClick?(): void;
}
const WidgetContextMenu: React.FC<IProps> = ({
export const WidgetContextMenu: React.FC<IProps> = ({
onFinished,
app,
userWidget,
@ -158,24 +161,31 @@ const WidgetContextMenu: React.FC<IProps> = ({
const isLocalWidget = WidgetType.JITSI.matches(app.type);
let revokeButton;
if (!userWidget && !isLocalWidget && isAllowedWidget) {
const onRevokeClick = (): void => {
logger.info("Revoking permission for widget to load: " + app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
if (app.eventId !== undefined) current[app.eventId] = false;
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
SettingsStore.setValue("allowedWidgets", roomId, level, current).catch((err) => {
logger.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
onFinished();
};
const opts: ApprovalOpts = { approved: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(app));
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
if (!opts.approved) {
const onRevokeClick = (): void => {
logger.info("Revoking permission for widget to load: " + app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
if (app.eventId !== undefined) current[app.eventId] = false;
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
if (!level) throw new Error("level must be defined");
SettingsStore.setValue("allowedWidgets", roomId ?? null, level, current).catch((err) => {
logger.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
onFinished();
};
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
}
}
let moveLeftButton;
if (showUnpin && widgetIndex > 0) {
const onClick = (): void => {
if (!room) throw new Error("room must be defined");
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
onFinished();
};
@ -207,5 +217,3 @@ const WidgetContextMenu: React.FC<IProps> = ({
</IconizedContextMenu>
);
};
export default WidgetContextMenu;

View file

@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
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.
@ -14,14 +15,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { useCallback } from "react";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import BaseDialog from "./BaseDialog";
interface IProps {
export interface AskInviteAnywayDialogProps {
unknownProfileUsers: Array<{
userId: string;
errorText: string;
@ -31,57 +32,58 @@ interface IProps {
onFinished: (success: boolean) => void;
}
export default class AskInviteAnywayDialog extends React.Component<IProps> {
private onInviteClicked = (): void => {
this.props.onInviteAnyways();
this.props.onFinished(true);
};
export default function AskInviteAnywayDialog({
onFinished,
onGiveUp,
onInviteAnyways,
unknownProfileUsers,
}: AskInviteAnywayDialogProps): JSX.Element {
const onInviteClicked = useCallback((): void => {
onInviteAnyways();
onFinished(true);
}, [onInviteAnyways, onFinished]);
private onInviteNeverWarnClicked = (): void => {
const onInviteNeverWarnClicked = useCallback((): void => {
SettingsStore.setValue("promptBeforeInviteUnknownUsers", null, SettingLevel.ACCOUNT, false);
this.props.onInviteAnyways();
this.props.onFinished(true);
};
onInviteAnyways();
onFinished(true);
}, [onInviteAnyways, onFinished]);
private onGiveUpClicked = (): void => {
this.props.onGiveUp();
this.props.onFinished(false);
};
const onGiveUpClicked = useCallback((): void => {
onGiveUp();
onFinished(false);
}, [onGiveUp, onFinished]);
public render(): React.ReactNode {
const errorList = this.props.unknownProfileUsers.map((address) => (
<li key={address.userId}>
{address.userId}: {address.errorText}
</li>
));
const errorList = unknownProfileUsers.map((address) => (
<li key={address.userId}>
{address.userId}: {address.errorText}
</li>
));
return (
<BaseDialog
className="mx_RetryInvitesDialog"
onFinished={this.onGiveUpClicked}
title={_t("The following users may not exist")}
contentId="mx_Dialog_content"
>
<div id="mx_Dialog_content">
<p>
{_t(
"Unable to find profiles for the Matrix IDs listed below - " +
"would you like to invite them anyway?",
)}
</p>
<ul>{errorList}</ul>
</div>
return (
<BaseDialog
className="mx_RetryInvitesDialog"
onFinished={onGiveUpClicked}
title={_t("The following users may not exist")}
contentId="mx_Dialog_content"
>
<div id="mx_Dialog_content">
<p>
{_t(
"Unable to find profiles for the Matrix IDs listed below - " +
"would you like to invite them anyway?",
)}
</p>
<ul>{errorList}</ul>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onGiveUpClicked}>{_t("Close")}</button>
<button onClick={this.onInviteNeverWarnClicked}>
{_t("Invite anyway and never warn me again")}
</button>
<button onClick={this.onInviteClicked} autoFocus={true}>
{_t("Invite anyway")}
</button>
</div>
</BaseDialog>
);
}
<div className="mx_Dialog_buttons">
<button onClick={onGiveUpClicked}>{_t("Close")}</button>
<button onClick={onInviteNeverWarnClicked}>{_t("Invite anyway and never warn me again")}</button>
<button onClick={onInviteClicked} autoFocus={true}>
{_t("Invite anyway")}
</button>
</div>
</BaseDialog>
);
}

View file

@ -36,7 +36,7 @@ interface IProps {
interface IState {
shouldLoadBackupStatus: boolean;
loading: boolean;
backupInfo: IKeyBackupInfo;
backupInfo: IKeyBackupInfo | null;
error?: string;
}
@ -55,7 +55,6 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
shouldLoadBackupStatus: shouldLoadBackupStatus,
loading: shouldLoadBackupStatus,
backupInfo: null,
error: null,
};
if (shouldLoadBackupStatus) {
@ -103,14 +102,20 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
// A key backup exists for this account, but the creating device is not
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
Modal.createDialog(RestoreKeyBackupDialog, null, null, /* priority = */ false, /* static = */ true);
Modal.createDialog(
RestoreKeyBackupDialog,
undefined,
undefined,
/* priority = */ false,
/* static = */ true,
);
} else {
Modal.createDialogAsync(
import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise<
ComponentType<{}>
>,
null,
null,
undefined,
undefined,
/* priority = */ false,
/* static = */ true,
);

View file

@ -20,7 +20,7 @@ import React, { useContext } from "react";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { useNotificationState } from "../../../../hooks/useRoomNotificationState";
import { _t } from "../../../../languageHandler";
import { _t, _td } from "../../../../languageHandler";
import { determineUnreadState } from "../../../../RoomNotifs";
import { humanReadableNotificationColor } from "../../../../stores/notifications/NotificationColor";
import { doesRoomOrThreadHaveUnreadMessages } from "../../../../Unread";
@ -39,22 +39,38 @@ export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Eleme
<h2>{_t("Room status")}</h2>
<ul>
<li>
{_t("Room unread status: ")}
<strong>{humanReadableNotificationColor(color)}</strong>
{count > 0 && (
<>
{_t(", count:")} <strong>{count}</strong>
</>
{_t(
"Room unread status: <strong>%(status)s</strong>, count: <strong>%(count)s</strong>",
{
status: humanReadableNotificationColor(color),
count,
},
{
strong: (sub) => <strong>{sub}</strong>,
},
)}
</li>
<li>
{_t("Notification state is")} <strong>{notificationState}</strong>
{_t(
"Notification state is <strong>%(notificationState)s</strong>",
{
notificationState,
},
{
strong: (sub) => <strong>{sub}</strong>,
},
)}
</li>
<li>
{_t("Room is ")}
<strong>
{cli.isRoomEncrypted(room.roomId!) ? _t("encrypted ✅") : _t("not encrypted 🚨")}
</strong>
{_t(
cli.isRoomEncrypted(room.roomId!)
? _td("Room is <strong>encrypted ✅</strong>")
: _td("Room is <strong>not encrypted 🚨</strong>"),
{},
{
strong: (sub) => <strong>{sub}</strong>,
},
)}
</li>
</ul>
</section>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix";
@ -23,7 +23,8 @@ import BaseDialog from "../BaseDialog";
import { IDialogProps } from "../IDialogProps";
import { PollHistoryList } from "./PollHistoryList";
import { PollHistoryFilter } from "./types";
import { usePolls } from "./usePollHistory";
import { usePollsWithRelations } from "./usePollHistory";
import { useFetchPastPolls } from "./fetchPastPolls";
type PollHistoryDialogProps = Pick<IDialogProps, "onFinished"> & {
roomId: string;
@ -34,7 +35,10 @@ const sortEventsByLatest = (left: MatrixEvent, right: MatrixEvent): number => ri
const filterPolls =
(filter: PollHistoryFilter) =>
(poll: Poll): boolean =>
(filter === "ACTIVE") !== poll.isEnded;
// exclude polls while they are still loading
// to avoid jitter in list
!poll.isFetchingResponses && (filter === "ACTIVE") !== poll.isEnded;
const filterAndSortPolls = (polls: Map<string, Poll>, filter: PollHistoryFilter): MatrixEvent[] => {
return [...polls.values()]
.filter(filterPolls(filter))
@ -43,18 +47,24 @@ const filterAndSortPolls = (polls: Map<string, Poll>, filter: PollHistoryFilter)
};
export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({ roomId, matrixClient, onFinished }) => {
const { polls } = usePolls(roomId, matrixClient);
const room = matrixClient.getRoom(roomId)!;
const { isLoading } = useFetchPastPolls(room, matrixClient);
const { polls } = usePollsWithRelations(roomId, matrixClient);
const [filter, setFilter] = useState<PollHistoryFilter>("ACTIVE");
const [pollStartEvents, setPollStartEvents] = useState(filterAndSortPolls(polls, filter));
useEffect(() => {
setPollStartEvents(filterAndSortPolls(polls, filter));
}, [filter, polls]);
const pollStartEvents = filterAndSortPolls(polls, filter);
const isLoadingPollResponses = [...polls.values()].some((poll) => poll.isFetchingResponses);
return (
<BaseDialog title={_t("Polls history")} onFinished={onFinished}>
<div className="mx_PollHistoryDialog_content">
<PollHistoryList pollStartEvents={pollStartEvents} filter={filter} onFilterChange={setFilter} />
<PollHistoryList
pollStartEvents={pollStartEvents}
isLoading={isLoading || isLoadingPollResponses}
polls={polls}
filter={filter}
onFilterChange={setFilter}
/>
</div>
</BaseDialog>
);

View file

@ -15,19 +15,41 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix";
import PollListItem from "./PollListItem";
import { _t } from "../../../../languageHandler";
import { FilterTabGroup } from "../../elements/FilterTabGroup";
import InlineSpinner from "../../elements/InlineSpinner";
import { PollHistoryFilter } from "./types";
import { PollListItem } from "./PollListItem";
import { PollListItemEnded } from "./PollListItemEnded";
const LoadingPolls: React.FC<{ noResultsYet?: boolean }> = ({ noResultsYet }) => (
<div
className={classNames("mx_PollHistoryList_loading", {
mx_PollHistoryList_noResultsYet: noResultsYet,
})}
>
<InlineSpinner />
{_t("Loading polls")}
</div>
);
type PollHistoryListProps = {
pollStartEvents: MatrixEvent[];
polls: Map<string, Poll>;
filter: PollHistoryFilter;
onFilterChange: (filter: PollHistoryFilter) => void;
isLoading?: boolean;
};
export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvents, filter, onFilterChange }) => {
export const PollHistoryList: React.FC<PollHistoryListProps> = ({
pollStartEvents,
polls,
filter,
isLoading,
onFilterChange,
}) => {
return (
<div className="mx_PollHistoryList">
<FilterTabGroup<PollHistoryFilter>
@ -39,19 +61,30 @@ export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvent
{ id: "ENDED", label: "Past polls" },
]}
/>
{!!pollStartEvents.length ? (
<ol className="mx_PollHistoryList_list">
{pollStartEvents.map((pollStartEvent) => (
<PollListItem key={pollStartEvent.getId()!} event={pollStartEvent} />
))}
{!!pollStartEvents.length && (
<ol className={classNames("mx_PollHistoryList_list", `mx_PollHistoryList_list_${filter}`)}>
{pollStartEvents.map((pollStartEvent) =>
filter === "ACTIVE" ? (
<PollListItem key={pollStartEvent.getId()!} event={pollStartEvent} />
) : (
<PollListItemEnded
key={pollStartEvent.getId()!}
event={pollStartEvent}
poll={polls.get(pollStartEvent.getId()!)!}
/>
),
)}
{isLoading && <LoadingPolls />}
</ol>
) : (
)}
{!pollStartEvents.length && !isLoading && (
<span className="mx_PollHistoryList_noResults">
{filter === "ACTIVE"
? _t("There are no active polls in this room")
: _t("There are no past polls in this room")}
</span>
)}
{!pollStartEvents.length && isLoading && <LoadingPolls noResultsYet />}
</div>
);
};

View file

@ -25,7 +25,7 @@ interface Props {
event: MatrixEvent;
}
const PollListItem: React.FC<Props> = ({ event }) => {
export const PollListItem: React.FC<Props> = ({ event }) => {
const pollEvent = event.unstableExtensibleEvent as unknown as PollStartEvent;
if (!pollEvent) {
return null;
@ -39,5 +39,3 @@ const PollListItem: React.FC<Props> = ({ event }) => {
</li>
);
};
export default PollListItem;

View file

@ -0,0 +1,127 @@
/*
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, { useEffect, useState } from "react";
import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { MatrixEvent, Poll, PollEvent } from "matrix-js-sdk/src/matrix";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { Icon as PollIcon } from "../../../../../res/img/element-icons/room/composer/poll.svg";
import { _t } from "../../../../languageHandler";
import { formatLocalDateShort } from "../../../../DateUtils";
import { allVotes, collectUserVotes, countVotes } from "../../messages/MPollBody";
import { PollOption } from "../../polls/PollOption";
import { Caption } from "../../typography/Caption";
interface Props {
event: MatrixEvent;
poll: Poll;
}
type EndedPollState = {
winningAnswers: {
answer: PollAnswerSubevent;
voteCount: number;
}[];
totalVoteCount: number;
};
const getWinningAnswers = (poll: Poll, responseRelations: Relations): EndedPollState => {
const userVotes = collectUserVotes(allVotes(responseRelations));
const votes = countVotes(userVotes, poll.pollEvent);
const totalVoteCount = [...votes.values()].reduce((sum, vote) => sum + vote, 0);
const winCount = Math.max(...votes.values());
return {
totalVoteCount,
winningAnswers: poll.pollEvent.answers
.filter((answer) => votes.get(answer.id) === winCount)
.map((answer) => ({
answer,
voteCount: votes.get(answer.id) || 0,
})),
};
};
/**
* Get deduplicated and validated poll responses
* Will use cached responses from Poll instance when existing
* Updates on changes to Poll responses (paging relations or from sync)
* Returns winning answers and total vote count
*/
const usePollVotes = (poll: Poll): Partial<EndedPollState> => {
const [results, setResults] = useState({ totalVoteCount: 0 });
useEffect(() => {
const getResponses = async (): Promise<void> => {
const responseRelations = await poll.getResponses();
setResults(getWinningAnswers(poll, responseRelations));
};
const onPollResponses = (responseRelations: Relations): void =>
setResults(getWinningAnswers(poll, responseRelations));
poll.on(PollEvent.Responses, onPollResponses);
getResponses();
return () => {
poll.off(PollEvent.Responses, onPollResponses);
};
}, [poll]);
return results;
};
/**
* Render an ended poll with the winning answer and vote count
* @param event - the poll start MatrixEvent
* @param poll - Poll instance
*/
export const PollListItemEnded: React.FC<Props> = ({ event, poll }) => {
const pollEvent = poll.pollEvent;
const { winningAnswers, totalVoteCount } = usePollVotes(poll);
if (!pollEvent) {
return null;
}
const formattedDate = formatLocalDateShort(event.getTs());
return (
<li data-testid={`pollListItem-${event.getId()!}`} className="mx_PollListItemEnded">
<div className="mx_PollListItemEnded_title">
<PollIcon className="mx_PollListItemEnded_icon" />
<span className="mx_PollListItemEnded_question">{pollEvent.question.text}</span>
<Caption>{formattedDate}</Caption>
</div>
{!!winningAnswers?.length && (
<div className="mx_PollListItemEnded_answers">
{winningAnswers?.map(({ answer, voteCount }) => (
<PollOption
key={answer.id}
answer={answer}
voteCount={voteCount}
totalVoteCount={totalVoteCount!}
pollId={poll.pollId}
displayVoteCount
isChecked
isEnded
/>
))}
</div>
)}
<div className="mx_PollListItemEnded_voteCount">
<Caption>{_t("Final result based on %(count)s votes", { count: totalVoteCount })}</Caption>
</div>
</li>
);
};

View file

@ -0,0 +1,129 @@
/*
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 { useEffect, useState } from "react";
import { M_POLL_START } from "matrix-js-sdk/src/@types/polls";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventTimeline, EventTimelineSet, Room } from "matrix-js-sdk/src/matrix";
import { Filter, IFilterDefinition } from "matrix-js-sdk/src/filter";
import { logger } from "matrix-js-sdk/src/logger";
/**
* Page timeline backwards until either:
* - event older than endOfHistoryPeriodTimestamp is encountered
* - end of timeline is reached
* @param timelineSet - timelineset to page
* @param matrixClient - client
* @param endOfHistoryPeriodTimestamp - epoch timestamp to fetch until
* @returns void
*/
const pagePolls = async (
timelineSet: EventTimelineSet,
matrixClient: MatrixClient,
endOfHistoryPeriodTimestamp: number,
): Promise<void> => {
const liveTimeline = timelineSet.getLiveTimeline();
const events = liveTimeline.getEvents();
const oldestEventTimestamp = events[0]?.getTs() || Date.now();
const hasMorePages = !!liveTimeline.getPaginationToken(EventTimeline.BACKWARDS);
if (!hasMorePages || oldestEventTimestamp <= endOfHistoryPeriodTimestamp) {
return;
}
await matrixClient.paginateEventTimeline(liveTimeline, {
backwards: true,
});
return pagePolls(timelineSet, matrixClient, endOfHistoryPeriodTimestamp);
};
const ONE_DAY_MS = 60000 * 60 * 24;
/**
* Fetches timeline history for given number of days in past
* @param timelineSet - timelineset to page
* @param matrixClient - client
* @param historyPeriodDays - number of days of history to fetch, from current day
* @returns isLoading - true while fetching history
*/
const useTimelineHistory = (
timelineSet: EventTimelineSet | null,
matrixClient: MatrixClient,
historyPeriodDays: number,
): { isLoading: boolean } => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!timelineSet) {
return;
}
const endOfHistoryPeriodTimestamp = Date.now() - ONE_DAY_MS * historyPeriodDays;
const doFetchHistory = async (): Promise<void> => {
setIsLoading(true);
try {
await pagePolls(timelineSet, matrixClient, endOfHistoryPeriodTimestamp);
} catch (error) {
logger.error("Failed to fetch room polls history", error);
} finally {
setIsLoading(false);
}
};
doFetchHistory();
}, [timelineSet, historyPeriodDays, matrixClient]);
return { isLoading };
};
const filterDefinition: IFilterDefinition = {
room: {
timeline: {
types: [M_POLL_START.name, M_POLL_START.altName],
},
},
};
/**
* Fetch poll start events in the last N days of room history
* @param room - room to fetch history for
* @param matrixClient - client
* @param historyPeriodDays - number of days of history to fetch, from current day
* @returns isLoading - true while fetching history
*/
export const useFetchPastPolls = (
room: Room,
matrixClient: MatrixClient,
historyPeriodDays = 30,
): { isLoading: boolean } => {
const [timelineSet, setTimelineSet] = useState<EventTimelineSet | null>(null);
useEffect(() => {
const filter = new Filter(matrixClient.getSafeUserId());
filter.setDefinition(filterDefinition);
const getFilteredTimelineSet = async (): Promise<void> => {
const filterId = await matrixClient.getOrCreateFilter(`POLL_HISTORY_FILTER_${room.roomId}}`, filter);
filter.filterId = filterId;
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
setTimelineSet(timelineSet);
};
getFilteredTimelineSet();
}, [room, matrixClient]);
const { isLoading } = useTimelineHistory(timelineSet, matrixClient, historyPeriodDays);
return { isLoading };
};

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useEffect, useState } from "react";
import { Poll, PollEvent } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
@ -21,6 +22,7 @@ import { useEventEmitterState } from "../../../../hooks/useEventEmitter";
/**
* Get poll instances from a room
* Updates to include new polls
* @param roomId - id of room to retrieve polls for
* @param matrixClient - client
* @returns {Map<string, Poll>} - Map of Poll instances
@ -37,9 +39,58 @@ export const usePolls = (
throw new Error("Cannot find room");
}
const polls = useEventEmitterState(room, PollEvent.New, () => room.polls);
// @TODO(kerrya) watch polls for end events, trigger refiltering
// copy room.polls map so changes can be detected
const polls = useEventEmitterState(room, PollEvent.New, () => new Map<string, Poll>(room.polls));
return { polls };
};
/**
* Get all poll instances from a room
* Fetch their responses (using cached poll responses)
* Updates on:
* - new polls added to room
* - new responses added to polls
* - changes to poll ended state
* @param roomId - id of room to retrieve polls for
* @param matrixClient - client
* @returns {Map<string, Poll>} - Map of Poll instances
*/
export const usePollsWithRelations = (
roomId: string,
matrixClient: MatrixClient,
): {
polls: Map<string, Poll>;
} => {
const { polls } = usePolls(roomId, matrixClient);
const [pollsWithRelations, setPollsWithRelations] = useState<Map<string, Poll>>(polls);
useEffect(() => {
const onPollUpdate = async (): Promise<void> => {
// trigger rerender by creating a new poll map
setPollsWithRelations(new Map(polls));
};
if (polls) {
for (const poll of polls.values()) {
// listen to changes in responses and end state
poll.on(PollEvent.End, onPollUpdate);
poll.on(PollEvent.Responses, onPollUpdate);
// trigger request to get all responses
// if they are not already in cache
poll.getResponses();
}
setPollsWithRelations(polls);
}
// unsubscribe
return () => {
if (polls) {
for (const poll of polls.values()) {
poll.off(PollEvent.End, onPollUpdate);
poll.off(PollEvent.Responses, onPollUpdate);
}
}
};
}, [polls, setPollsWithRelations]);
return { polls: pollsWithRelations };
};

View file

@ -93,6 +93,7 @@ import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom";
import { shouldShowFeedback } from "../../../../utils/Feedback";
import RoomAvatar from "../../avatars/RoomAvatar";
import { useFeatureEnabled } from "../../../../hooks/useSettings";
import { filterBoolean } from "../../../../utils/arrays";
const MAX_RECENT_SEARCHES = 10;
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
@ -173,13 +174,13 @@ const toPublicRoomResult = (publicRoom: IPublicRoomsChunkRoom): IPublicRoomResul
publicRoom,
section: Section.PublicRooms,
filter: [Filter.PublicRooms],
query: [
query: filterBoolean([
publicRoom.room_id.toLowerCase(),
publicRoom.canonical_alias?.toLowerCase(),
publicRoom.name?.toLowerCase(),
sanitizeHtml(publicRoom.topic?.toLowerCase() ?? "", { allowedTags: [] }),
...(publicRoom.aliases?.map((it) => it.toLowerCase()) || []),
].filter(Boolean) as string[],
]),
});
const toRoomResult = (room: Room): IRoomResult => {

View file

@ -23,6 +23,7 @@ import classNames from "classnames";
import { MatrixCapabilities } from "matrix-widget-api";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger";
import { ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
import AccessibleButton from "./AccessibleButton";
import { _t } from "../../../languageHandler";
@ -36,7 +37,7 @@ import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
import PersistedElement, { getPersistKey } from "./PersistedElement";
import { WidgetType } from "../../../widgets/WidgetType";
import { ElementWidget, StopGapWidget } from "../../../stores/widgets/StopGapWidget";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
import LegacyCallHandler from "../../../LegacyCallHandler";
import { IApp } from "../../../stores/WidgetStore";
@ -50,6 +51,7 @@ import { Action } from "../../../dispatcher/actions";
import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidgetCapabilities";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { ModuleRunner } from "../../../modules/ModuleRunner";
interface IProps {
app: IApp;
@ -162,6 +164,9 @@ export default class AppTile extends React.Component<IProps, IState> {
private hasPermissionToLoad = (props: IProps): boolean => {
if (this.usingLocalWidget()) return true;
if (!props.room) return true; // user widgets always have permissions
const opts: ApprovalOpts = { approved: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(this.props.app));
if (opts.approved) return true;
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
const allowed = props.app.eventId !== undefined && (currentlyAllowedWidgets[props.app.eventId] ?? false);

View file

@ -32,7 +32,7 @@ interface IProps {
/* callback to update the value. Called with a single argument: the new
* value. */
onSubmit?: (value: string) => Promise<{} | void>;
onSubmit: (value: string) => Promise<{} | void>;
/* should the input submit when focus is lost? */
blurToSubmit?: boolean;
@ -40,7 +40,7 @@ interface IProps {
interface IState {
busy: boolean;
errorString: string;
errorString: string | null;
value: string;
}
@ -72,7 +72,7 @@ export default class EditableTextContainer extends React.Component<IProps, IStat
this.state = {
busy: false,
errorString: null,
value: props.initialValue,
value: props.initialValue ?? "",
};
}

View file

@ -113,7 +113,7 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
brandClass = `mx_SSOButton_brand_${brandName}`;
icon = <img src={brandIcon} height="24" width="24" alt={brandName} />;
} else if (typeof idp?.icon === "string" && idp.icon.startsWith("mxc://")) {
const src = mediaFromMxc(idp.icon, matrixClient).getSquareThumbnailHttp(24);
const src = mediaFromMxc(idp.icon, matrixClient).getSquareThumbnailHttp(24) ?? undefined;
icon = <img src={src} height="24" width="24" alt={idp.name} />;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ReactNode } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import EventIndexPeg from "../../../indexing/EventIndexPeg";
@ -31,13 +31,13 @@ export enum WarningKind {
}
interface IProps {
isRoomEncrypted: boolean;
isRoomEncrypted?: boolean;
kind: WarningKind;
}
export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.Element {
if (!isRoomEncrypted) return null;
if (EventIndexPeg.get()) return null;
if (!isRoomEncrypted) return <></>;
if (EventIndexPeg.get()) return <></>;
if (EventIndexPeg.error) {
return (
@ -69,8 +69,8 @@ export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.El
const brand = SdkConfig.get("brand");
const desktopBuilds = SdkConfig.getObject("desktop_builds");
let text = null;
let logo = null;
let text: ReactNode | undefined;
let logo: JSX.Element | undefined;
if (desktopBuilds.get("available")) {
logo = <img src={desktopBuilds.get("logo")} />;
const buildUrl = desktopBuilds.get("url");
@ -116,7 +116,7 @@ export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.El
// for safety
if (!text) {
logger.warn("Unknown desktop builds warning kind: ", kind);
return null;
return <></>;
}
return (

View file

@ -26,7 +26,7 @@ interface IResult {
text: string;
}
interface IRule<T, D = void> {
interface IRule<T, D = undefined> {
key: string;
final?: boolean;
skip?(this: T, data: Data, derivedData: D): boolean;
@ -90,14 +90,12 @@ export default function withValidation<T = void, D = void>({
{ value, focused, allowEmpty = true }: IFieldState,
): Promise<IValidationResult> {
if (!value && allowEmpty) {
return {
valid: null,
feedback: null,
};
return {};
}
const data = { value, allowEmpty };
const derivedData: D | undefined = deriveData ? await deriveData.call(this, data) : undefined;
// We know that if deriveData is set then D will not be undefined
const derivedData: D = (await deriveData?.call(this, data)) as D;
const results: IResult[] = [];
let valid = true;
@ -149,10 +147,7 @@ export default function withValidation<T = void, D = void>({
// Hide feedback when not focused
if (!focused) {
return {
valid,
feedback: null,
};
return { valid };
}
let details;

View file

@ -38,7 +38,7 @@ interface IProps {
id: string;
name: string;
emojis: IEmoji[];
selectedEmojis: Set<string>;
selectedEmojis?: Set<string>;
heightBefore: number;
viewportHeight: number;
scrollTop: number;

View file

@ -26,6 +26,7 @@ import Search from "./Search";
import Preview from "./Preview";
import QuickReactions from "./QuickReactions";
import Category, { ICategory, CategoryKey } from "./Category";
import { filterBoolean } from "../../../utils/arrays";
export const CATEGORY_HEADER_HEIGHT = 20;
export const EMOJI_HEIGHT = 35;
@ -62,13 +63,12 @@ class EmojiPicker extends React.Component<IProps, IState> {
this.state = {
filter: "",
previewEmoji: null,
scrollTop: 0,
viewportHeight: 280,
};
// Convert recent emoji characters to emoji data, removing unknowns and duplicates
this.recentlyUsed = Array.from(new Set(recent.get().map(getEmojiFromUnicode).filter(Boolean)));
this.recentlyUsed = Array.from(new Set(filterBoolean(recent.get().map(getEmojiFromUnicode))));
this.memoizedDataByCategory = {
recent: this.recentlyUsed,
...DATA_BY_CATEGORY,
@ -230,9 +230,9 @@ class EmojiPicker extends React.Component<IProps, IState> {
});
};
private onHoverEmojiEnd = (emoji: IEmoji): void => {
private onHoverEmojiEnd = (): void => {
this.setState({
previewEmoji: null,
previewEmoji: undefined,
});
};

View file

@ -42,9 +42,7 @@ interface IState {
class QuickReactions extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
hover: null,
};
this.state = {};
}
private onMouseEnter = (emoji: IEmoji): void => {
@ -55,7 +53,7 @@ class QuickReactions extends React.Component<IProps, IState> {
private onMouseLeave = (): void => {
this.setState({
hover: null,
hover: undefined,
});
};

View file

@ -77,8 +77,8 @@ class ReactionPicker extends React.Component<IProps, IState> {
if (!this.props.reactions) {
return {};
}
const userId = MatrixClientPeg.get().getUserId();
const myAnnotations = this.props.reactions.getAnnotationsBySender()[userId] || new Set<MatrixEvent>();
const userId = MatrixClientPeg.get().getUserId()!;
const myAnnotations = this.props.reactions.getAnnotationsBySender()?.[userId] ?? new Set<MatrixEvent>();
return Object.fromEntries(
[...myAnnotations]
.filter((event) => !event.isRedacted())
@ -97,9 +97,9 @@ class ReactionPicker extends React.Component<IProps, IState> {
this.props.onFinished();
const myReactions = this.getReactions();
if (myReactions.hasOwnProperty(reaction)) {
if (this.props.mxEvent.isRedacted() || !this.context.canSelfRedact) return;
if (this.props.mxEvent.isRedacted() || !this.context.canSelfRedact) return false;
MatrixClientPeg.get().redactEvent(this.props.mxEvent.getRoomId(), myReactions[reaction]);
MatrixClientPeg.get().redactEvent(this.props.mxEvent.getRoomId()!, myReactions[reaction]);
dis.dispatch<FocusComposerPayload>({
action: Action.FocusAComposer,
context: this.context.timelineRenderingType,
@ -107,7 +107,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
// Tell the emoji picker not to bump this in the more frequently used list.
return false;
} else {
MatrixClientPeg.get().sendEvent(this.props.mxEvent.getRoomId(), EventType.Reaction, {
MatrixClientPeg.get().sendEvent(this.props.mxEvent.getRoomId()!, EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: this.props.mxEvent.getId(),

View file

@ -32,7 +32,7 @@ class Search extends React.PureComponent<IProps> {
public componentDidMount(): void {
// For some reason, neither the autoFocus nor just calling focus() here worked, so here's a window.setTimeout
window.setTimeout(() => this.inputRef.current.focus(), 0);
window.setTimeout(() => this.inputRef.current?.focus(), 0);
}
private onKeyDown = (ev: React.KeyboardEvent): void => {

View file

@ -29,7 +29,7 @@ interface Props {
export const EnableLiveShare: React.FC<Props> = ({ onSubmit }) => {
const [isEnabled, setEnabled] = useState(false);
return (
<div data-test-id="location-picker-enable-live-share" className="mx_EnableLiveShare">
<div data-testid="location-picker-enable-live-share" className="mx_EnableLiveShare">
<StyledLiveBeaconIcon className="mx_EnableLiveShare_icon" />
<Heading className="mx_EnableLiveShare_heading" size="h3">
{_t("Live location sharing")}
@ -43,13 +43,13 @@ export const EnableLiveShare: React.FC<Props> = ({ onSubmit }) => {
)}
</p>
<LabelledToggleSwitch
data-test-id="enable-live-share-toggle"
data-testid="enable-live-share-toggle"
value={isEnabled}
onChange={setEnabled}
label={_t("Enable live location sharing")}
/>
<AccessibleButton
data-test-id="enable-live-share-submit"
data-testid="enable-live-share-submit"
className="mx_EnableLiveShare_button"
element="button"
kind="primary"

View file

@ -62,7 +62,7 @@ const LiveDurationDropdown: React.FC<Props> = ({ timeout, onChange }) => {
return (
<Dropdown
id="live-duration"
data-test-id="live-duration-dropdown"
data-testid="live-duration-dropdown"
label={getLabel(timeout)}
value={timeout.toString()}
onOptionChange={onOptionChange}

View file

@ -231,7 +231,7 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
<LiveDurationDropdown onChange={this.onTimeoutChange} timeout={this.state.timeout} />
)}
<AccessibleButton
data-test-id="location-picker-submit-button"
data-testid="location-picker-submit-button"
type="submit"
element="button"
kind="primary"

View file

@ -33,7 +33,7 @@ export interface MapErrorProps {
export const MapError: React.FC<MapErrorProps> = ({ error, isMinimised, className, onFinished, onClick }) => (
<div
data-test-id="map-rendering-error"
data-testid="map-rendering-error"
className={classNames("mx_MapError", className, { mx_MapError_isMinimised: isMinimised })}
onClick={onClick}
>

View file

@ -19,6 +19,7 @@ import React from "react";
import AccessibleButton from "../elements/AccessibleButton";
import { Icon as BackIcon } from "../../../../res/img/element-icons/caret-left.svg";
import { Icon as CloseIcon } from "../../../../res/img/element-icons/cancel-rounded.svg";
import { _t } from "../../../languageHandler";
interface Props {
onCancel: () => void;
@ -32,7 +33,8 @@ const ShareDialogButtons: React.FC<Props> = ({ onBack, onCancel, displayBack })
{displayBack && (
<AccessibleButton
className="mx_ShareDialogButtons_button left"
data-test-id="share-dialog-buttons-back"
data-testid="share-dialog-buttons-back"
aria-label={_t("Back")}
onClick={onBack}
element="button"
>
@ -41,7 +43,8 @@ const ShareDialogButtons: React.FC<Props> = ({ onBack, onCancel, displayBack })
)}
<AccessibleButton
className="mx_ShareDialogButtons_button right"
data-test-id="share-dialog-buttons-cancel"
data-testid="share-dialog-buttons-cancel"
aria-label={_t("Close")}
onClick={onCancel}
element="button"
>

View file

@ -73,7 +73,7 @@ export default class DateSeparator extends React.Component<IProps, IState> {
}
public componentWillUnmount(): void {
SettingsStore.unwatchSetting(this.settingWatcherRef);
if (this.settingWatcherRef) SettingsStore.unwatchSetting(this.settingWatcherRef);
}
private onContextMenuOpenClick = (e: React.MouseEvent): void => {
@ -89,7 +89,7 @@ export default class DateSeparator extends React.Component<IProps, IState> {
private closeMenu = (): void => {
this.setState({
contextMenuPosition: null,
contextMenuPosition: undefined,
});
};
@ -181,7 +181,7 @@ export default class DateSeparator extends React.Component<IProps, IState> {
};
private renderJumpToDateMenu(): React.ReactElement {
let contextMenu: JSX.Element;
let contextMenu: JSX.Element | undefined;
if (this.state.contextMenuPosition) {
contextMenu = (
<IconizedContextMenu

View file

@ -14,14 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { IBodyProps } from "./IBodyProps";
// A placeholder element for messages that could not be decrypted
export default class DecryptionFailureBody extends React.Component<Partial<IBodyProps>> {
public render(): ReactNode {
return <div className="mx_DecryptionFailureBody mx_EventTile_content">{_t("Unable to decrypt message")}</div>;
}
function getErrorMessage(mxEvent?: MatrixEvent): string {
return mxEvent?.isEncryptedDisabledForUnverifiedDevices
? _t("The sender has blocked you from receiving this message")
: _t("Unable to decrypt message");
}
// A placeholder element for messages that could not be decrypted
export function DecryptionFailureBody({ mxEvent }: Partial<IBodyProps>): JSX.Element {
return <div className="mx_DecryptionFailureBody mx_EventTile_content">{getErrorMessage(mxEvent)}</div>;
}

View file

@ -191,6 +191,13 @@ export default class LegacyCallEvent extends React.PureComponent<IProps, IState>
{this.props.timestamp}
</div>
);
} else if (hangupReason === CallErrorCode.AnsweredElsewhere) {
return (
<div className="mx_LegacyCallEvent_content">
{_t("Answered elsewhere")}
{this.props.timestamp}
</div>
);
}
let reason;

View file

@ -182,12 +182,14 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
private addListeners(): void {
this.state.poll?.on(PollEvent.Responses, this.onResponsesChange);
this.state.poll?.on(PollEvent.End, this.onRelationsChange);
this.state.poll?.on(PollEvent.UndecryptableRelations, this.render.bind(this));
}
private removeListeners(): void {
if (this.state.poll) {
this.state.poll.off(PollEvent.Responses, this.onResponsesChange);
this.state.poll.off(PollEvent.End, this.onRelationsChange);
this.state.poll.off(PollEvent.UndecryptableRelations, this.render.bind(this));
}
}
@ -297,7 +299,9 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
const showResults = poll.isEnded || (disclosed && myVote !== undefined);
let totalText: string;
if (poll.isEnded) {
if (showResults && poll.undecryptableRelationsCount) {
totalText = _t("Due to decryption errors, some votes may not be counted");
} else if (poll.isEnded) {
totalText = _t("Final result based on %(count)s votes", { count: totalVotes });
} else if (!disclosed) {
totalText = _t("Results will be visible when the poll is ended");
@ -384,7 +388,7 @@ export function allVotes(voteRelations: Relations): Array<UserVote> {
* @param {string?} selected Local echo selected option for the userId
* @returns a Map of user ID to their vote info
*/
function collectUserVotes(
export function collectUserVotes(
userResponses: Array<UserVote>,
userId?: string | null | undefined,
selected?: string | null | undefined,
@ -405,7 +409,7 @@ function collectUserVotes(
return userVotes;
}
function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent): Map<string, number> {
export function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent): Map<string, number> {
const collected = new Map<string, number>();
for (const response of userVotes.values()) {

View file

@ -21,7 +21,9 @@ import { logger } from "matrix-js-sdk/src/logger";
import { Icon as PollIcon } from "../../../../res/img/element-icons/room/composer/poll.svg";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { _t } from "../../../languageHandler";
import { textForEvent } from "../../../TextForEvent";
import { Caption } from "../typography/Caption";
import { IBodyProps } from "./IBodyProps";
import MPollBody from "./MPollBody";
@ -105,5 +107,10 @@ export const MPollEndBody = React.forwardRef<any, IBodyProps>(({ mxEvent, ...pro
);
}
return <MPollBody mxEvent={pollStartEvent} {...props} />;
return (
<div>
<Caption>{_t("Ended a poll")}</Caption>
<MPollBody mxEvent={pollStartEvent} {...props} />
</div>
);
});

View file

@ -41,7 +41,7 @@ import { MPollEndBody } from "./MPollEndBody";
import MLocationBody from "./MLocationBody";
import MjolnirBody from "./MjolnirBody";
import MBeaconBody from "./MBeaconBody";
import DecryptionFailureBody from "./DecryptionFailureBody";
import { DecryptionFailureBody } from "./DecryptionFailureBody";
import { GetRelationsForEvent, IEventTileOps } from "../rooms/EventTile";
import { VoiceBroadcastBody, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../voice-broadcast";

View file

@ -40,7 +40,7 @@ import { E2EStatus } from "../../../utils/ShieldUtils";
import RoomContext from "../../../contexts/RoomContext";
import { UIComponent, UIFeature } from "../../../settings/UIFeature";
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
import { useRoomMemberCount } from "../../../hooks/useRoomMembers";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { usePinnedEvents } from "./PinnedMessagesCard";

View file

@ -24,7 +24,7 @@ import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler";
import { useWidgets } from "./RoomSummaryCard";
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import UIStore from "../../../stores/UIStore";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";

View file

@ -399,7 +399,7 @@ export default class AliasSettings extends React.Component<IProps, IState> {
return (
<div className="mx_AliasSettings">
<SettingsFieldset
data-test-id="published-address-fieldset"
data-testid="published-address-fieldset"
legend={_t("Published Addresses")}
description={
<>
@ -450,7 +450,7 @@ export default class AliasSettings extends React.Component<IProps, IState> {
/>
</SettingsFieldset>
<SettingsFieldset
data-test-id="local-address-fieldset"
data-testid="local-address-fieldset"
legend={_t("Local Addresses")}
description={
isSpaceRoom

View file

@ -35,7 +35,7 @@ import { Layout } from "../../../settings/enums/Layout";
import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DecryptionFailureBody from "../messages/DecryptionFailureBody";
import { DecryptionFailureBody } from "../messages/DecryptionFailureBody";
import { E2EState } from "./E2EIcon";
import RoomAvatar from "../avatars/RoomAvatar";
import MessageContextMenu from "../context_menus/MessageContextMenu";
@ -1270,7 +1270,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{this.props.mxEvent.isRedacted() ? (
<RedactedBody mxEvent={this.props.mxEvent} />
) : this.props.mxEvent.isDecryptionFailure() ? (
<DecryptionFailureBody />
<DecryptionFailureBody mxEvent={this.props.mxEvent} />
) : (
MessagePreviewStore.instance.generatePreviewForEvent(this.props.mxEvent)
)}

View file

@ -1,5 +1,5 @@
/*
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Copyright 2020 - 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.
@ -21,8 +21,9 @@ import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../
import NotificationBadge from "./NotificationBadge";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import { ButtonEvent } from "../elements/AccessibleButton";
import useHover from "../../../hooks/useHover";
interface IProps {
interface ExtraTileProps {
isMinimized: boolean;
isSelected: boolean;
displayName: string;
@ -31,83 +32,68 @@ interface IProps {
onClick: (ev: ButtonEvent) => void;
}
interface IState {
hover: boolean;
}
export default function ExtraTile({
isSelected,
isMinimized,
notificationState,
displayName,
onClick,
avatar,
}: ExtraTileProps): JSX.Element {
const [, { onMouseOver, onMouseLeave }] = useHover(() => false);
export default class ExtraTile extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
// XXX: We copy classes because it's easier
const classes = classNames({
mx_ExtraTile: true,
mx_RoomTile: true,
mx_RoomTile_selected: isSelected,
mx_RoomTile_minimized: isMinimized,
});
this.state = {
hover: false,
};
let badge: JSX.Element | null = null;
if (notificationState) {
badge = <NotificationBadge notification={notificationState} forceCount={false} />;
}
private onTileMouseEnter = (): void => {
this.setState({ hover: true });
};
let name = displayName;
if (typeof name !== "string") name = "";
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
private onTileMouseLeave = (): void => {
this.setState({ hover: false });
};
const nameClasses = classNames({
mx_RoomTile_title: true,
mx_RoomTile_titleHasUnreadEvents: notificationState?.isUnread,
});
public render(): React.ReactElement {
// XXX: We copy classes because it's easier
const classes = classNames({
mx_ExtraTile: true,
mx_RoomTile: true,
mx_RoomTile_selected: this.props.isSelected,
mx_RoomTile_minimized: this.props.isMinimized,
});
let nameContainer: JSX.Element | null = (
<div className="mx_RoomTile_titleContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
</div>
</div>
);
if (isMinimized) nameContainer = null;
let badge;
if (this.props.notificationState) {
badge = <NotificationBadge notification={this.props.notificationState} forceCount={false} />;
}
let Button = RovingAccessibleButton;
if (isMinimized) {
Button = RovingAccessibleTooltipButton;
}
let name = this.props.displayName;
if (typeof name !== "string") name = "";
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
const nameClasses = classNames({
mx_RoomTile_title: true,
mx_RoomTile_titleHasUnreadEvents: this.props.notificationState?.isUnread,
});
let nameContainer = (
<div className="mx_RoomTile_titleContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
return (
<Button
className={classes}
onMouseEnter={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={onClick}
role="treeitem"
title={isMinimized ? name : undefined}
>
<div className="mx_RoomTile_avatarContainer">{avatar}</div>
<div className="mx_RoomTile_details">
<div className="mx_RoomTile_primaryDetails">
{nameContainer}
<div className="mx_RoomTile_badgeContainer">{badge}</div>
</div>
</div>
);
if (this.props.isMinimized) nameContainer = null;
let Button = RovingAccessibleButton;
if (this.props.isMinimized) {
Button = RovingAccessibleTooltipButton;
}
return (
<React.Fragment>
<Button
className={classes}
onMouseEnter={this.onTileMouseEnter}
onMouseLeave={this.onTileMouseLeave}
onClick={this.props.onClick}
role="treeitem"
title={this.props.isMinimized ? name : undefined}
>
<div className="mx_RoomTile_avatarContainer">{this.props.avatar}</div>
<div className="mx_RoomTile_details">
<div className="mx_RoomTile_primaryDetails">
{nameContainer}
<div className="mx_RoomTile_badgeContainer">{badge}</div>
</div>
</div>
</Button>
</React.Fragment>
);
}
</Button>
);
}

View file

@ -241,7 +241,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
private waitForOwnMember(): void {
// If we have the member already, do that
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId());
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId()!);
if (me) {
this.setState({ me });
return;
@ -250,14 +250,14 @@ export class MessageComposer extends React.Component<IProps, IState> {
// The members should already be loading, and loadMembersIfNeeded
// will return the promise for the existing operation
this.props.room.loadMembersIfNeeded().then(() => {
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId());
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId()!);
this.setState({ me });
});
}
public componentWillUnmount(): void {
VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate);
dis.unregister(this.dispatcherRef);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);
@ -268,12 +268,12 @@ export class MessageComposer extends React.Component<IProps, IState> {
private onTombstoneClick = (ev: ButtonEvent): void => {
ev.preventDefault();
const replacementRoomId = this.context.tombstone.getContent()["replacement_room"];
const replacementRoomId = this.context.tombstone?.getContent()["replacement_room"];
const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId);
let createEventId = null;
let createEventId: string | undefined;
if (replacementRoom) {
const createEvent = replacementRoom.currentState.getStateEvents(EventType.RoomCreate, "");
if (createEvent && createEvent.getId()) createEventId = createEvent.getId();
if (createEvent?.getId()) createEventId = createEvent.getId();
}
const viaServers = [this.context.tombstone.getSender().split(":").slice(1).join(":")];
@ -408,7 +408,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
private onRecordingEndingSoon = ({ secondsLeft }: { secondsLeft: number }): void => {
this.setState({ recordingTimeLeftSeconds: secondsLeft });
window.setTimeout(() => this.setState({ recordingTimeLeftSeconds: null }), 3000);
window.setTimeout(() => this.setState({ recordingTimeLeftSeconds: undefined }), 3000);
};
private setStickerPickerOpen = (isStickerPickerOpen: boolean): void => {

View file

@ -38,7 +38,7 @@ export function StatelessNotificationBadge({ symbol, count, color, ...props }: P
// Don't show a badge if we don't need to
if (color === NotificationColor.None || (hideBold && color == NotificationColor.Bold)) {
return null;
return <></>;
}
const hasUnreadCount = color >= NotificationColor.Grey && (!!count || !!symbol);
@ -54,8 +54,8 @@ export function StatelessNotificationBadge({ symbol, count, color, ...props }: P
mx_NotificationBadge_visible: isEmptyBadge ? true : hasUnreadCount,
mx_NotificationBadge_highlighted: color >= NotificationColor.Red,
mx_NotificationBadge_dot: isEmptyBadge,
mx_NotificationBadge_2char: symbol?.length > 0 && symbol?.length < 3,
mx_NotificationBadge_3char: symbol?.length > 2,
mx_NotificationBadge_2char: symbol && symbol.length > 0 && symbol.length < 3,
mx_NotificationBadge_3char: symbol && symbol.length > 2,
});
if (props.onClick) {

View file

@ -62,7 +62,7 @@ export default class PinnedEventTile extends React.Component<IProps> {
eventId: string,
relationType: RelationType | string,
eventType: EventType | string,
): Relations => {
): Relations | undefined => {
if (eventId === this.props.event.getId()) {
return this.relations.get(relationType)?.get(eventType);
}
@ -71,7 +71,7 @@ export default class PinnedEventTile extends React.Component<IProps> {
public render(): React.ReactNode {
const sender = this.props.event.getSender();
let unpinButton = null;
let unpinButton: JSX.Element | undefined;
if (this.props.onUnpinClicked) {
unpinButton = (
<AccessibleTooltipButton

View file

@ -66,7 +66,7 @@ export function determineAvatarPosition(index: number, max: number): IAvatarPosi
}
}
export function readReceiptTooltip(members: string[], hasMore: boolean): string | null {
export function readReceiptTooltip(members: string[], hasMore: boolean): string | undefined {
if (hasMore) {
return _t("%(members)s and more", {
members: members.join(", "),
@ -78,8 +78,6 @@ export function readReceiptTooltip(members: string[], hasMore: boolean): string
});
} else if (members.length) {
return members[0];
} else {
return null;
}
}
@ -134,7 +132,7 @@ export function ReadReceiptGroup({
const { hidden, position } = determineAvatarPosition(index, maxAvatars);
const userId = receipt.userId;
let readReceiptInfo: IReadReceiptInfo;
let readReceiptInfo: IReadReceiptInfo | undefined;
if (readReceiptMap) {
readReceiptInfo = readReceiptMap[userId];
@ -161,7 +159,7 @@ export function ReadReceiptGroup({
})
.reverse();
let remText: JSX.Element;
let remText: JSX.Element | undefined;
const remainder = readReceipts.length - maxAvatars;
if (remainder > 0) {
remText = (

View file

@ -133,7 +133,7 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
target.top = 0;
target.right = 0;
target.parent = null;
target.parent = undefined;
return target;
}
// this is the mx_ReadReceiptsGroup
@ -146,7 +146,7 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
target.top = 0;
target.right = 0;
target.parent = null;
target.parent = undefined;
return target;
}
@ -179,7 +179,7 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
: // treat new RRs as though they were off the top of the screen
-READ_AVATAR_SIZE;
const startStyles = [];
const startStyles: IReadReceiptMarkerStyle[] = [];
if (oldInfo?.right) {
startStyles.push({
top: oldPosition - newPosition,
@ -210,7 +210,7 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
return (
<NodeAnimator startStyles={this.state.startStyles}>
<MemberAvatar
member={this.props.member}
member={this.props.member ?? null}
fallbackUserId={this.props.fallbackUserId}
aria-hidden="true"
aria-live="off"

View file

@ -168,7 +168,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
...this.props,
// overrides
ref: null,
ref: undefined,
showUrlPreview: false,
overrideBodyTypes: msgtypeOverrides,
overrideEventTypes: evOverrides,

View file

@ -37,5 +37,5 @@ export function RoomContextDetails<T extends keyof ReactHTML>({ room, component,
);
}
return null;
return <></>;
}

View file

@ -62,7 +62,7 @@ const RoomInfoLine: FC<IProps> = ({ room }) => {
roomType = room.isSpaceRoom() ? _t("Private space") : _t("Private room");
}
let members: JSX.Element;
let members: JSX.Element | undefined;
if (membership === "invite" && summary) {
// Don't trust local state and instead use the summary API
members = (

View file

@ -117,7 +117,7 @@ const auxButtonContextMenuPosition = (handle: RefObject<HTMLDivElement>): MenuPr
const DmAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex, dispatcher = defaultDispatcher }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const activeSpace: Room = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpaceRoom;
});
@ -125,7 +125,7 @@ const DmAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex, dispatcher = default
const showInviteUsers = shouldShowComponent(UIComponent.InviteUsers);
if (activeSpace && (showCreateRooms || showInviteUsers)) {
let contextMenu: JSX.Element;
let contextMenu: JSX.Element | undefined;
if (menuDisplayed) {
const canInvite = shouldShowSpaceInvite(activeSpace);
@ -208,7 +208,7 @@ const DmAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex, dispatcher = default
const UntaggedAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const activeSpace = useEventEmitterState<Room>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
const activeSpace = useEventEmitterState<Room | null>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpaceRoom;
});
@ -216,11 +216,11 @@ const UntaggedAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex }) => {
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
let contextMenuContent: JSX.Element | null = null;
let contextMenuContent: JSX.Element | undefined;
if (menuDisplayed && activeSpace) {
const canAddRooms = activeSpace.currentState.maySendStateEvent(
EventType.SpaceChild,
MatrixClientPeg.get().getUserId(),
MatrixClientPeg.get().getUserId()!,
);
contextMenuContent = (
@ -469,13 +469,13 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
SpaceStore.instance.off(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
SettingsStore.unwatchSetting(this.favouriteMessageWatcher);
defaultDispatcher.unregister(this.dispatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
}
private onRoomViewStoreUpdate = (): void => {
this.setState({
currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId(),
currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined,
});
};
@ -629,7 +629,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
Object.values(RoomListStore.instance.orderedLists).every((list) => !list?.length);
return TAG_ORDER.map((orderedTagId) => {
let extraTiles = null;
let extraTiles: ReactComponentElement<typeof ExtraTile>[] | undefined;
if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
} else if (this.state.feature_favourite_messages && orderedTagId === DefaultTagID.SavedItems) {

View file

@ -137,12 +137,10 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
}
}, [closeMainMenu, canShowMainMenu, mainMenuDisplayed]);
const spaceName = useTypedEventEmitterState(activeSpace, RoomEvent.Name, () => activeSpace?.name);
const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name);
useEffect(() => {
if (onVisibilityChange) {
onVisibilityChange();
}
onVisibilityChange?.();
}, [onVisibilityChange]);
const canExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
@ -151,7 +149,7 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
const hasPermissionToAddSpaceChild = activeSpace?.currentState?.maySendStateEvent(
EventType.SpaceChild,
cli.getUserId(),
cli.getUserId()!,
);
const canAddSubRooms = hasPermissionToAddSpaceChild && canCreateRooms;
const canAddSubSpaces = hasPermissionToAddSpaceChild && canCreateSpaces;
@ -161,7 +159,7 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
// communities and spaces, but is at risk of no options on the Home tab.
const canShowPlusMenu = canCreateRooms || canExploreRooms || canCreateSpaces || activeSpace;
let contextMenu: JSX.Element;
let contextMenu: JSX.Element | undefined;
if (mainMenuDisplayed && mainMenuHandle.current) {
let ContextMenuComponent;
if (activeSpace) {
@ -179,7 +177,7 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
/>
);
} else if (plusMenuDisplayed && activeSpace) {
let inviteOption: JSX.Element;
let inviteOption: JSX.Element | undefined;
if (shouldShowSpaceInvite(activeSpace)) {
inviteOption = (
<IconizedContextMenuOption
@ -195,8 +193,8 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
);
}
let newRoomOptions: JSX.Element;
if (activeSpace?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId())) {
let newRoomOptions: JSX.Element | undefined;
if (activeSpace?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()!)) {
newRoomOptions = (
<>
<IconizedContextMenuOption
@ -265,7 +263,9 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
closePlusMenu();
}}
disabled={!canAddSubRooms}
tooltip={!canAddSubRooms && _t("You do not have permissions to add rooms to this space")}
tooltip={
!canAddSubRooms ? _t("You do not have permissions to add rooms to this space") : undefined
}
/>
{canCreateSpaces && (
<IconizedContextMenuOption
@ -278,7 +278,11 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
closePlusMenu();
}}
disabled={!canAddSubSpaces}
tooltip={!canAddSubSpaces && _t("You do not have permissions to add spaces to this space")}
tooltip={
!canAddSubSpaces
? _t("You do not have permissions to add spaces to this space")
: undefined
}
>
<BetaPill />
</IconizedContextMenuOption>
@ -287,8 +291,8 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
</IconizedContextMenu>
);
} else if (plusMenuDisplayed) {
let newRoomOpts: JSX.Element;
let joinRoomOpt: JSX.Element;
let newRoomOpts: JSX.Element | undefined;
let joinRoomOpt: JSX.Element | undefined;
if (canCreateRooms) {
newRoomOpts = (
@ -366,7 +370,7 @@ const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
}
let title: string;
if (activeSpace) {
if (activeSpace && spaceName) {
title = spaceName;
} else {
title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ReactNode } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
@ -76,6 +76,12 @@ interface IProps {
canPreview?: boolean;
previewLoading?: boolean;
// The id of the room to be previewed, if it is known.
// (It may be unknown if we are waiting for an alias to be resolved.)
roomId?: string;
// A `Room` object for the room to be previewed, if we have one.
room?: Room;
loading?: boolean;
@ -215,25 +221,27 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
return { memberName, reason };
}
private joinRule(): JoinRule {
return this.props.room?.currentState
.getStateEvents(EventType.RoomJoinRules, "")
?.getContent<IJoinRuleEventContent>().join_rule;
private joinRule(): JoinRule | null {
return (
this.props.room?.currentState
.getStateEvents(EventType.RoomJoinRules, "")
?.getContent<IJoinRuleEventContent>().join_rule ?? null
);
}
private getMyMember(): RoomMember {
return this.props.room?.getMember(MatrixClientPeg.get().getUserId());
private getMyMember(): RoomMember | null {
return this.props.room?.getMember(MatrixClientPeg.get().getUserId()!) ?? null;
}
private getInviteMember(): RoomMember {
private getInviteMember(): RoomMember | null {
const { room } = this.props;
if (!room) {
return;
return null;
}
const myUserId = MatrixClientPeg.get().getUserId();
const myUserId = MatrixClientPeg.get().getUserId()!;
const inviteEvent = room.currentState.getMember(myUserId);
if (!inviteEvent) {
return;
return null;
}
const inviterUserId = inviteEvent.events.member.getSender();
return room.currentState.getMember(inviterUserId);
@ -276,15 +284,15 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
const isSpace = this.props.room?.isSpaceRoom() ?? this.props.oobData?.roomType === RoomType.Space;
let showSpinner = false;
let title;
let subTitle;
let reasonElement;
let primaryActionHandler;
let primaryActionLabel;
let secondaryActionHandler;
let secondaryActionLabel;
let footer;
const extraComponents = [];
let title: string | undefined;
let subTitle: string | ReactNode[] | undefined;
let reasonElement: JSX.Element | undefined;
let primaryActionHandler: (() => void) | undefined;
let primaryActionLabel: string | undefined;
let secondaryActionHandler: (() => void) | undefined;
let secondaryActionLabel: string | undefined;
let footer: JSX.Element | undefined;
const extraComponents: JSX.Element[] = [];
const messageCase = this.getMessageCase();
switch (messageCase) {
@ -310,18 +318,14 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
}
case MessageCase.NotLoggedIn: {
const opts: RoomPreviewOpts = { canJoin: false };
if (this.props.room?.roomId) {
ModuleRunner.instance.invoke(
RoomViewLifecycle.PreviewRoomNotLoggedIn,
opts,
this.props.room.roomId,
);
if (this.props.roomId) {
ModuleRunner.instance.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.roomId);
}
if (opts.canJoin) {
title = _t("Join the room to participate");
primaryActionLabel = _t("Join");
primaryActionHandler = () => {
ModuleRunner.instance.invoke(RoomViewLifecycle.JoinFromRoomPreview, this.props.room.roomId);
ModuleRunner.instance.invoke(RoomViewLifecycle.JoinFromRoomPreview, this.props.roomId);
};
} else {
title = _t("Join the conversation with an account");
@ -349,7 +353,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
} else {
title = _t("You were removed by %(memberName)s", { memberName });
}
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : undefined;
if (isSpace) {
primaryActionLabel = _t("Forget this space");
@ -374,7 +378,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
} else {
title = _t("You were banned by %(memberName)s", { memberName });
}
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : undefined;
if (isSpace) {
primaryActionLabel = _t("Forget this space");
} else {
@ -497,7 +501,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
primaryActionLabel = _t("Accept");
}
const myUserId = MatrixClientPeg.get().getUserId();
const myUserId = MatrixClientPeg.get().getUserId()!;
const member = this.props.room?.currentState.getMember(myUserId);
const memberEventContent = member?.events.member?.getContent();

View file

@ -22,7 +22,7 @@ import { Dispatcher } from "flux";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import * as React from "react";
import { ComponentType, createRef, ReactComponentElement } from "react";
import { ComponentType, createRef, ReactComponentElement, ReactNode } from "react";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
@ -82,7 +82,7 @@ interface IProps {
alwaysVisible?: boolean;
forceExpanded?: boolean;
resizeNotifier: ResizeNotifier;
extraTiles?: ReactComponentElement<typeof ExtraTile>[];
extraTiles?: ReactComponentElement<typeof ExtraTile>[] | null;
onListCollapse?: (isExpanded: boolean) => void;
}
@ -95,7 +95,7 @@ interface ResizeDelta {
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState {
contextMenuPosition: PartialDOMRect;
contextMenuPosition?: PartialDOMRect;
isResizing: boolean;
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number;
@ -123,7 +123,6 @@ export default class RoomSublist extends React.Component<IProps, IState> {
this.heightAtStart = 0;
this.notificationState = RoomNotificationStateStore.instance.getListState(this.props.tagId);
this.state = {
contextMenuPosition: null,
isResizing: false,
isExpanded: !this.layout.isCollapsed,
height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
@ -160,17 +159,14 @@ export default class RoomSublist extends React.Component<IProps, IState> {
}
private get extraTiles(): ReactComponentElement<typeof ExtraTile>[] | null {
if (this.props.extraTiles) {
return this.props.extraTiles;
}
return null;
return this.props.extraTiles ?? null;
}
private get numTiles(): number {
return RoomSublist.calcNumTiles(this.state.rooms, this.extraTiles);
}
private static calcNumTiles(rooms: Room[], extraTiles: any[]): number {
private static calcNumTiles(rooms: Room[], extraTiles?: any[] | null): number {
return (rooms || []).length + (extraTiles || []).length;
}
@ -390,7 +386,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
};
private onCloseMenu = (): void => {
this.setState({ contextMenuPosition: null });
this.setState({ contextMenuPosition: undefined });
};
private onUnreadFirstChanged = (): void => {
@ -506,7 +502,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
// On ArrowLeft go to the sublist header
case KeyBindingAction.ArrowLeft:
ev.stopPropagation();
this.headerButton.current.focus();
this.headerButton.current?.focus();
break;
// Consume ArrowRight so it doesn't cause focus to get sent to composer
case KeyBindingAction.ArrowRight:
@ -557,10 +553,10 @@ export default class RoomSublist extends React.Component<IProps, IState> {
return tiles;
}
private renderMenu(): React.ReactElement {
private renderMenu(): ReactNode {
if (this.props.tagId === DefaultTagID.Suggested || this.props.tagId === DefaultTagID.SavedItems) return null; // not sortable
let contextMenu = null;
let contextMenu: JSX.Element | undefined;
if (this.state.contextMenuPosition) {
let isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
let isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
@ -571,7 +567,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
}
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections = null;
let otherSections: JSX.Element | undefined;
if (this.props.tagId !== DefaultTagID.Invite) {
otherSections = (
<React.Fragment>
@ -665,7 +661,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
/>
);
let addRoomButton = null;
let addRoomButton: JSX.Element | undefined;
if (this.props.AuxButtonComponent) {
const AuxButtonComponent = this.props.AuxButtonComponent;
addRoomButton = <AuxButtonComponent tabIndex={tabIndex} />;
@ -747,7 +743,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
mx_RoomSublist_hidden: hidden,
});
let content = null;
let content: JSX.Element | undefined;
if (this.state.roomsLoading) {
content = <div className="mx_RoomSublist_skeletonUI" />;
} else if (visibleTiles.length > 0 && this.props.forceExpanded) {
@ -773,7 +769,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
// If we're hiding rooms, show a 'show more' button to the user. This button
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
let showNButton = null;
let showNButton: JSX.Element | undefined;
const hasMoreSlidingSync =
this.slidingSyncMode && RoomListStore.instance.getCount(this.props.tagId) > this.state.rooms.length;
@ -786,7 +782,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
numMissing = RoomListStore.instance.getCount(this.props.tagId) - amountFullyShown;
}
const label = _t("Show %(count)s more", { count: numMissing });
let showMoreText = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
let showMoreText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showMoreText = null;
showNButton = (
<RovingAccessibleButton
@ -804,7 +800,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
} else if (this.numTiles > this.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less
const label = _t("Show less");
let showLessText = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
let showLessText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showLessText = null;
showNButton = (
<RovingAccessibleButton

View file

@ -72,13 +72,13 @@ export default class SearchBar extends React.Component<IProps, IState> {
};
private searchIfQuery(): void {
if (this.searchTerm.current.value) {
if (this.searchTerm.current?.value) {
this.onSearch();
}
}
private onSearch = (): void => {
if (!this.searchTerm.current.value.trim()) return;
if (!this.searchTerm.current?.value.trim()) return;
this.props.onSearch(this.searchTerm.current.value, this.state.scope);
};

View file

@ -368,7 +368,12 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation?.event_id : null;
let commandSuccessful: boolean;
[content, commandSuccessful] = await runSlashCommand(cmd, args, this.props.room.roomId, threadId);
[content, commandSuccessful] = await runSlashCommand(
cmd,
args,
this.props.room.roomId,
threadId ?? null,
);
if (!commandSuccessful) {
return; // errored
}
@ -425,7 +430,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
const prom = doMaybeLocalRoomAction(
roomId,
(actualRoomId: string) => this.props.mxClient.sendMessage(actualRoomId, threadId, content),
(actualRoomId: string) => this.props.mxClient.sendMessage(actualRoomId, threadId ?? null, content!),
this.props.mxClient,
);
if (replyToEvent) {
@ -439,7 +444,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
}
dis.dispatch({ action: "message_sent" });
CHAT_EFFECTS.forEach((effect) => {
if (containsEmoji(content, effect.emojis)) {
if (containsEmoji(content!, effect.emojis)) {
// For initial threads launch, chat effects are disabled
// see #19731
const isNotThread = this.props.relation?.rel_type !== THREAD_RELATION_TYPE.name;

View file

@ -52,9 +52,9 @@ interface IProps {
}
interface IState {
imError: string;
stickerpickerWidget: IWidgetEvent;
widgetId: string;
imError: string | null;
stickerpickerWidget: IWidgetEvent | null;
widgetId: string | null;
}
export default class Stickerpicker extends React.PureComponent<IProps, IState> {
@ -71,7 +71,7 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
private popoverWidth = 300;
private popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
private scalarClient: ScalarAuthClient = null;
private scalarClient: ScalarAuthClient | null = null;
public constructor(props: IProps) {
super(props);
@ -82,13 +82,13 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
};
}
private acquireScalarClient(): Promise<void | ScalarAuthClient> {
private async acquireScalarClient(): Promise<void | undefined | null | ScalarAuthClient> {
if (this.scalarClient) return Promise.resolve(this.scalarClient);
// TODO: Pick the right manager for the widget
if (IntegrationManagers.sharedInstance().hasManager()) {
this.scalarClient = IntegrationManagers.sharedInstance().getPrimaryManager().getScalarClient();
this.scalarClient = IntegrationManagers.sharedInstance().getPrimaryManager()?.getScalarClient() ?? null;
return this.scalarClient
.connect()
?.connect()
.then(() => {
this.forceUpdate();
return this.scalarClient;
@ -170,21 +170,14 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
private updateWidget = (): void => {
const stickerpickerWidget = WidgetUtils.getStickerpickerWidgets()[0];
if (!stickerpickerWidget) {
Stickerpicker.currentWidget = null;
Stickerpicker.currentWidget = undefined;
this.setState({ stickerpickerWidget: null, widgetId: null });
return;
}
const currentWidget = Stickerpicker.currentWidget;
let currentUrl = null;
if (currentWidget && currentWidget.content && currentWidget.content.url) {
currentUrl = currentWidget.content.url;
}
let newUrl = null;
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
newUrl = stickerpickerWidget.content.url;
}
const currentUrl = currentWidget?.content?.url ?? null;
const newUrl = stickerpickerWidget?.content?.url ?? null;
if (newUrl !== currentUrl) {
// Destroy the existing frame so a new one can be created
@ -238,7 +231,7 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
private sendVisibilityToWidget(visible: boolean): void {
if (!this.state.stickerpickerWidget) return;
const messaging = WidgetMessagingStore.instance.getMessagingForUid(
WidgetUtils.calcWidgetUid(this.state.stickerpickerWidget.id, null),
WidgetUtils.calcWidgetUid(this.state.stickerpickerWidget.id),
);
if (messaging && visible !== this.prevSentVisibility) {
messaging.updateVisibility(visible).catch((err) => {
@ -300,8 +293,8 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
room={this.props.room}
threadId={this.props.threadId}
fullWidth={true}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
userId={MatrixClientPeg.get().credentials.userId!}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId!}
waitForIframeLoad={true}
showMenubar={true}
onEditClick={this.launchManageIntegrations}
@ -347,8 +340,8 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
private launchManageIntegrations = (): void => {
// noinspection JSIgnoredPromiseFromCall
IntegrationManagers.sharedInstance()
.getPrimaryManager()
.open(this.props.room, `type_${WidgetType.STICKERPICKER.preferred}`, this.state.widgetId);
?.getPrimaryManager()
?.open(this.props.room, `type_${WidgetType.STICKERPICKER.preferred}`, this.state.widgetId ?? undefined);
};
public render(): React.ReactNode {

View file

@ -45,19 +45,19 @@ interface IState {
}
export default class ThirdPartyMemberInfo extends React.Component<IProps, IState> {
private room: Room;
private readonly room: Room | null;
public constructor(props: IProps) {
super(props);
this.room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
const me = this.room.getMember(MatrixClientPeg.get().getUserId());
const powerLevels = this.room.currentState.getStateEvents("m.room.power_levels", "");
const me = this.room?.getMember(MatrixClientPeg.get().getUserId()!);
const powerLevels = this.room?.currentState.getStateEvents("m.room.power_levels", "");
let kickLevel = powerLevels ? powerLevels.getContent().kick : 50;
if (typeof kickLevel !== "number") kickLevel = 50;
const sender = this.room.getMember(this.props.event.getSender());
const sender = this.room?.getMember(this.props.event.getSender());
this.state = {
stateKey: this.props.event.getStateKey(),
@ -121,7 +121,7 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
};
public render(): React.ReactNode {
let adminTools = null;
let adminTools: JSX.Element | undefined;
if (this.state.canKick && this.state.invited) {
adminTools = (
<div className="mx_MemberInfo_container">
@ -135,8 +135,8 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
);
}
let scopeHeader;
if (this.room.isSpaceRoom()) {
let scopeHeader: JSX.Element | undefined;
if (this.room?.isSpaceRoom()) {
scopeHeader = (
<div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} />

View file

@ -77,18 +77,18 @@ interface IPreviewProps {
export const ThreadMessagePreview: React.FC<IPreviewProps> = ({ thread, showDisplayname = false }) => {
const cli = useContext(MatrixClientContext);
const lastReply = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.replyToEvent);
const lastReply = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.replyToEvent) ?? undefined;
// track the content as a means to regenerate the thread message preview upon edits & decryption
const [content, setContent] = useState<IContent>(lastReply?.getContent());
const [content, setContent] = useState<IContent | undefined>(lastReply?.getContent());
useTypedEventEmitter(lastReply, MatrixEventEvent.Replaced, () => {
setContent(lastReply.getContent());
setContent(lastReply!.getContent());
});
const awaitDecryption = lastReply?.shouldAttemptDecryption() || lastReply?.isBeingDecrypted();
useTypedEventEmitter(awaitDecryption ? lastReply : null, MatrixEventEvent.Decrypted, () => {
setContent(lastReply.getContent());
useTypedEventEmitter(awaitDecryption ? lastReply : undefined, MatrixEventEvent.Decrypted, () => {
setContent(lastReply!.getContent());
});
const preview = useAsyncMemo(async (): Promise<string> => {
const preview = useAsyncMemo(async (): Promise<string | undefined> => {
if (!lastReply) return;
await cli.decryptEventIfNeeded(lastReply);
return MessagePreviewStore.instance.generatePreviewForEvent(lastReply);

View file

@ -20,8 +20,8 @@ import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
interface IProps {
onScrollUpClick?: (e: React.MouseEvent) => void;
onCloseClick?: (e: React.MouseEvent) => void;
onScrollUpClick: (e: React.MouseEvent) => void;
onCloseClick: (e: React.MouseEvent) => void;
}
export default class TopUnreadMessagesBar extends React.PureComponent<IProps> {

View file

@ -48,7 +48,7 @@ import { createVoiceMessageContent } from "../../../utils/createVoiceMessageCont
interface IProps {
room: Room;
permalinkCreator?: RoomPermalinkCreator;
permalinkCreator: RoomPermalinkCreator;
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
}
@ -70,9 +70,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
public constructor(props: IProps) {
super(props);
this.state = {
recorder: null, // no recording started by default
};
this.state = {};
this.voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
}
@ -163,7 +161,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await VoiceRecordingStore.instance.disposeRecording(this.voiceRecordingId);
// Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
this.setState({ recorder: undefined, recordingPhase: undefined, didUploadFail: false });
}
private onCancel = async (): Promise<void> => {
@ -220,7 +218,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
try {
// stop any noises which might be happening
PlaybackManager.instance.pauseAllExcept(null);
PlaybackManager.instance.pauseAllExcept();
const recorder = VoiceRecordingStore.instance.startRecording(this.voiceRecordingId);
await recorder.start();

Some files were not shown because too many files have changed in this diff Show more