Merge remote-tracking branch 'origin/develop' into staging

This commit is contained in:
RiotRobot 2024-05-29 12:50:06 +00:00
commit ca16462265
155 changed files with 2930 additions and 1851 deletions

View file

@ -62,12 +62,13 @@
"resolutions": {
"@types/react-dom": "17.0.25",
"@types/react": "17.0.80",
"@types/seedrandom": "3.0.4",
"oidc-client-ts": "3.0.1",
"jwt-decode": "4.0.0"
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.20.0",
"@matrix-org/analytics-events": "^0.21.0",
"@matrix-org/emojibase-bindings": "^1.1.2",
"@matrix-org/matrix-wysiwyg": "2.17.0",
"@matrix-org/olm": "3.2.15",
@ -76,11 +77,12 @@
"@sentry/browser": "^7.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@vector-im/compound-design-tokens": "^1.2.0",
"@vector-im/compound-web": "^4.2.0",
"@vector-im/compound-web": "^4.3.1",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"await-lock": "^2.1.0",
"bloom-filters": "^3.0.1",
"blurhash": "^2.0.3",
"classnames": "^2.2.6",
"commonmark": "^0.31.0",
@ -90,7 +92,7 @@
"emojibase-regex": "15.3.0",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "10.1.1",
"filesize": "10.1.2",
"gfm.css": "^1.1.2",
"glob-to-regexp": "^0.4.1",
"graphemer": "^1.4.0",
@ -116,7 +118,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.130.1",
"posthog-js": "1.131.4",
"proposal-temporal": "^0.9.0",
"qrcode": "1.5.3",
"re-resizable": "^6.9.0",
@ -182,12 +184,13 @@
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.11.0",
"@types/sdp-transform": "^2.4.6",
"@types/seedrandom": "3.0.4",
"@types/tar-js": "^0.3.2",
"@types/ua-parser-js": "^0.7.36",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"axe-core": "4.9.0",
"axe-core": "4.9.1",
"babel-jest": "^29.0.0",
"blob-polyfill": "^7.0.0",
"eslint": "8.57.0",
@ -200,7 +203,7 @@
"eslint-plugin-matrix-org": "1.2.1",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-unicorn": "^53.0.0",
"express": "^4.18.2",
"fake-indexeddb": "^5.0.2",
"fetch-mock-jest": "^1.5.1",

View file

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/playwright:v1.43.1-jammy
FROM mcr.microsoft.com/playwright:v1.44.0-jammy
WORKDIR /work/matrix-react-sdk
VOLUME ["/work/element-web/node_modules"]

View file

@ -240,15 +240,7 @@ test.describe("User verification", () => {
test.use({
displayName: "Alice",
botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" },
});
test("can receive a verification request when there is no existing DM", async ({
page,
app,
bot: bob,
user: aliceCredentials,
toasts,
}) => {
room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
// the other user creates a DM
@ -257,7 +249,17 @@ test.describe("User verification", () => {
// accept the DM
await app.viewRoomByName("Bob");
await page.getByRole("button", { name: "Start chatting" }).click();
await use({ roomId: dmRoomId });
},
});
test("can receive a verification request when there is no existing DM", async ({
page,
bot: bob,
user: aliceCredentials,
toasts,
room: { roomId: dmRoomId },
}) => {
// once Alice has joined, Bob starts the verification
const bobVerificationRequest = await bob.evaluateHandle(
async (client, { dmRoomId, aliceCredentials }) => {
@ -294,6 +296,51 @@ test.describe("User verification", () => {
await expect(page.getByText("You've successfully verified Bob!")).toBeVisible();
await page.getByRole("button", { name: "Got it" }).click();
});
test("can abort emoji verification when emoji mismatch", async ({
page,
bot: bob,
user: aliceCredentials,
toasts,
room: { roomId: dmRoomId },
cryptoBackend,
}) => {
test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto");
// once Alice has joined, Bob starts the verification
const bobVerificationRequest = await bob.evaluateHandle(
async (client, { dmRoomId, aliceCredentials }) => {
const room = client.getRoom(dmRoomId);
while (room.getMember(aliceCredentials.userId)?.membership !== "join") {
await new Promise((resolve) => {
room.once(window.matrixcs.RoomStateEvent.Members, resolve);
});
}
return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId);
},
{ dmRoomId, aliceCredentials },
);
// Accept verification via toast
const toast = await toasts.getToast("Verification requested");
await toast.getByRole("button", { name: "Verify Session" }).click();
// request verification by emoji
await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click();
/* on the bot side, wait for the verifier to exist ... */
const botVerifier = await awaitVerifier(bobVerificationRequest);
// ... confirm ...
botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {});
// ... and abort the verification
await page.getByRole("button", { name: "They don't match" }).click();
const dialog = page.locator(".mx_Dialog");
await expect(dialog.getByText("Your messages are not secure")).toBeVisible();
await dialog.getByRole("button", { name: "OK" }).click();
await expect(dialog).not.toBeVisible();
});
});
/** Extract the qrcode out of an on-screen html element */

View file

@ -276,4 +276,26 @@ test.describe("Room Header", () => {
await expect(header).toMatchScreenshot("room-header-with-apps-button-not-highlighted.png");
});
});
test.describe("with encryption", () => {
test("should render the E2E icon and the buttons", async ({ page, app, user }) => {
// Create an encrypted room
await app.client.createRoom({
name: "Test Encrypted Room",
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
});
await app.viewRoomByName("Test Encrypted Room");
const header = page.locator(".mx_LegacyRoomHeader");
await expect(header).toMatchScreenshot("encrypted-room-header.png");
});
});
});

View file

@ -133,9 +133,7 @@ test.describe("General user settings tab", () => {
test("should support adding and removing a profile picture", async ({ uut }) => {
const profileSettings = uut.locator(".mx_ProfileSettings");
// Upload a picture
await profileSettings
.locator(".mx_ProfileSettings_avatarUpload")
.setInputFiles("playwright/sample-files/riot.png");
await profileSettings.getByAltText("Upload").setInputFiles("playwright/sample-files/riot.png");
// Find and click "Remove" link button
await profileSettings.locator(".mx_ProfileSettings_profile").getByRole("button", { name: "Remove" }).click();

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -44,24 +44,3 @@ limitations under the License.
}
}
}
.mx_Tooltip.mx_Tooltip--appPermission {
box-shadow: none;
background-color: $tooltip-timeline-bg-color;
color: $tooltip-timeline-fg-color;
border: none;
border-radius: 3px;
padding: 6px 8px;
&.mx_Tooltip--appPermission--dark {
.mx_Tooltip_chevron::after {
border-right-color: $tooltip-timeline-bg-color;
}
}
ul {
list-style-position: inside;
padding-left: 2px;
margin-left: 0;
}
}

View file

@ -472,3 +472,10 @@ limitations under the License.
.mx_SpacePanel_sharePublicSpace {
margin: 0;
}
.mx_SpacePanel_Tooltip_KeyboardShortcut {
kbd {
font-family: inherit;
text-transform: capitalize;
}
}

View file

@ -167,7 +167,7 @@ limitations under the License.
}
/* Hide the labels on tabs, showing only the icons, on narrow viewports. */
@media (max-width: 768px) {
@media (max-width: 1024px) {
.mx_TabbedView_tabsOnLeft.mx_TabbedView_responsive {
.mx_TabbedView_tabLabel_text {
display: none;

View file

@ -204,7 +204,7 @@ limitations under the License.
}
.mx_LegacyCallEvent_info {
align-items: unset;
align-items: center;
}
}
}

View file

@ -65,6 +65,11 @@ limitations under the License.
.mx_BetaCard_betaPill {
margin-right: $spacing-8;
}
/* The container of E2EIcon in the legacy header needs to have its height set */
& > span {
height: 100%;
}
}
.mx_LegacyRoomHeader_name {

View file

@ -23,6 +23,7 @@ limitations under the License.
.mx_AvatarSetting_hover {
transition: opacity var(--hover-transition);
opacity: 0;
/* position to place the hover bg over the entire thing */
position: absolute;
@ -50,14 +51,10 @@ limitations under the License.
}
}
&.mx_AvatarSetting_avatar_hovering .mx_AvatarSetting_hover {
&.mx_AvatarSetting_avatarDisplay:hover .mx_AvatarSetting_hover {
opacity: 1;
}
&:not(.mx_AvatarSetting_avatar_hovering) .mx_AvatarSetting_hover {
opacity: 0;
}
& > * {
box-sizing: border-box;
}

View file

@ -17,10 +17,6 @@ limitations under the License.
.mx_ProfileSettings {
border-bottom: 1px solid $quinary-content;
.mx_ProfileSettings_avatarUpload {
display: none;
}
.mx_ProfileSettings_profile {
display: flex;

View file

@ -14,38 +14,64 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { ScalableBloomFilter } from "bloom-filters";
import { CryptoEvent, HttpApiEvent, MatrixClient, MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescript/Error";
import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
import { PosthogAnalytics } from "./PosthogAnalytics";
/** The key that we use to store the `reportedEvents` bloom filter in localstorage */
const DECRYPTION_FAILURE_STORAGE_KEY = "mx_decryption_failure_event_ids";
export class DecryptionFailure {
public readonly ts: number;
/**
* The time between our initial failure to decrypt and our successful
* decryption (if we managed to decrypt).
*/
public timeToDecryptMillis?: number;
public constructor(
public readonly failedEventId: string,
public readonly errorCode: DecryptionFailureCode,
) {
this.ts = Date.now();
}
/**
* The time that we failed to decrypt the event. If we failed to decrypt
* multiple times, this will be the time of the first failure.
*/
public readonly ts: number,
/**
* Is the sender on a different server from us?
*/
public readonly isFederated: boolean | undefined,
/**
* Was the failed event ever visible to the user?
*/
public wasVisibleToUser: boolean,
/**
* Has the user verified their own cross-signing identity, as of the most
* recent decryption attempt for this event?
*/
public userTrustsOwnIdentity: boolean | undefined,
) {}
}
type ErrorCode = ErrorEvent["name"];
type TrackingFn = (count: number, trackedErrCode: ErrorCode, rawError: string) => void;
/** Properties associated with decryption errors, for classifying the error. */
export type ErrorProperties = Omit<ErrorEvent, "eventName" | "domain" | "name" | "context">;
type TrackingFn = (trackedErrCode: ErrorCode, rawError: string, properties: ErrorProperties) => void;
export type ErrCodeMapFn = (errcode: DecryptionFailureCode) => ErrorCode;
export class DecryptionFailureTracker {
private static internalInstance = new DecryptionFailureTracker(
(total, errorCode, rawError) => {
for (let i = 0; i < total; i++) {
PosthogAnalytics.instance.trackEvent<ErrorEvent>({
(errorCode, rawError, properties) => {
const event: ErrorEvent = {
eventName: "Error",
domain: "E2EE",
name: errorCode,
context: `mxc_crypto_error_type_${rawError}`,
});
}
...properties,
};
PosthogAnalytics.instance.trackEvent<ErrorEvent>(event);
},
(errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
@ -66,51 +92,69 @@ export class DecryptionFailureTracker {
},
);
// Map of event IDs to DecryptionFailure items.
/** Map of event IDs to `DecryptionFailure` items.
*
* Every `CHECK_INTERVAL_MS`, this map is checked for failures that happened >
* `MAXIMUM_LATE_DECRYPTION_PERIOD` ago (considered undecryptable), or
* decryptions that took > `GRACE_PERIOD_MS` (considered late decryptions).
*
* Any such events are then reported via the `TrackingFn`.
*/
public failures: Map<string, DecryptionFailure> = new Map();
// Set of event IDs that have been visible to the user.
/** Set of event IDs that have been visible to the user.
*
* This will only contain events that are not already in `reportedEvents`.
*/
public visibleEvents: Set<string> = new Set();
// Map of visible event IDs to `DecryptionFailure`s. Every
// `CHECK_INTERVAL_MS`, this map is checked for failures that
// happened > `GRACE_PERIOD_MS` ago. Those that did are
// accumulated in `failureCounts`.
public visibleFailures: Map<string, DecryptionFailure> = new Map();
/** Bloom filter tracking event IDs of failures that were reported previously */
private reportedEvents: ScalableBloomFilter = new ScalableBloomFilter();
/**
* A histogram of the number of failures that will be tracked at the next tracking
* interval, split by failure error code.
*/
private failureCounts: Map<DecryptionFailureCode, number> = new Map();
// Event IDs of failures that were tracked previously
public trackedEvents: Set<string> = new Set();
// Set to an interval ID when `start` is called
/** Set to an interval ID when `start` is called */
public checkInterval: number | null = null;
public trackInterval: number | null = null;
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
public static TRACK_INTERVAL_MS = 60000;
// Call `checkFailures` every `CHECK_INTERVAL_MS`.
/** Call `checkFailures` every `CHECK_INTERVAL_MS`. */
public static CHECK_INTERVAL_MS = 40000;
// Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before counting
// the failure in `failureCounts`.
public static GRACE_PERIOD_MS = 30000;
/** If the event is successfully decrypted in less than 4s, we don't report. */
public static GRACE_PERIOD_MS = 4000;
/** Maximum time for an event to be decrypted to be considered a late
* decryption. If it takes longer, we consider it undecryptable. */
public static MAXIMUM_LATE_DECRYPTION_PERIOD = 60000;
/** Properties that will be added to all reported events (mainly reporting
* information about the Matrix client). */
private baseProperties?: ErrorProperties = {};
/** The user's domain (homeserver name). */
private userDomain?: string;
/** Whether the user has verified their own cross-signing keys. */
private userTrustsOwnIdentity: boolean | undefined = undefined;
/** Whether we are currently checking our own verification status. */
private checkingVerificationStatus: boolean = false;
/** Whether we should retry checking our own verification status after we're
* done our current check. i.e. we got notified that our keys changed while
* we were already checking, so the result could be out of date. */
private retryVerificationStatus: boolean = false;
/**
* Create a new DecryptionFailureTracker.
*
* Call `eventDecrypted(event, err)` on this instance when an event is decrypted.
*
* Call `start()` to start the tracker, and `stop()` to stop tracking.
* Call `start(client)` to start the tracker. The tracker will listen for
* decryption events on the client and track decryption failures, and will
* automatically stop tracking when the client logs out.
*
* @param {function} fn The tracking function, which will be called when failures
* are tracked. The function should have a signature `(count, trackedErrorCode) => {...}`,
* where `count` is the number of failures and `errorCode` matches the output of `errorCodeMapFn`.
* are tracked. The function should have a signature `(trackedErrorCode, rawError, properties) => {...}`,
* where `errorCode` matches the output of `errorCodeMapFn`, `rawError` is the original
* error (that is, the input to `errorCodeMapFn`), and `properties` is a map of the
* error properties for classifying the error.
*
* @param {function} errorCodeMapFn The function used to map decryption failure reason codes to the
* `trackedErrorCode`.
@ -132,130 +176,253 @@ export class DecryptionFailureTracker {
return DecryptionFailureTracker.internalInstance;
}
// loadTrackedEvents() {
// this.trackedEvents = new Set(JSON.parse(localStorage.getItem('mx-decryption-failure-event-ids')) || []);
// }
private loadReportedEvents(): void {
const storedFailures = localStorage.getItem(DECRYPTION_FAILURE_STORAGE_KEY);
if (storedFailures) {
this.reportedEvents = ScalableBloomFilter.fromJSON(JSON.parse(storedFailures));
} else {
this.reportedEvents = new ScalableBloomFilter();
}
}
// saveTrackedEvents() {
// localStorage.setItem('mx-decryption-failure-event-ids', JSON.stringify([...this.trackedEvents]));
// }
private saveReportedEvents(): void {
localStorage.setItem(DECRYPTION_FAILURE_STORAGE_KEY, JSON.stringify(this.reportedEvents.saveAsJSON()));
}
public eventDecrypted(e: MatrixEvent): void {
/** Callback for when an event is decrypted.
*
* This function is called by our `MatrixEventEvent.Decrypted` event
* handler after a decryption attempt on an event, whether the decryption
* is successful or not.
*
* @param e the event that was decrypted
*
* @param nowTs the current timestamp
*/
private eventDecrypted(e: MatrixEvent, nowTs: number): void {
// for now we only track megolm decryption failures
if (e.getWireContent().algorithm != "m.megolm.v1.aes-sha2") {
return;
}
const errCode = e.decryptionFailureReason;
if (errCode !== null) {
this.addDecryptionFailure(new DecryptionFailure(e.getId()!, errCode));
} else {
if (errCode === null) {
// Could be an event in the failures, remove it
this.removeDecryptionFailuresForEvent(e);
this.removeDecryptionFailuresForEvent(e, nowTs);
return;
}
const eventId = e.getId()!;
// if it's already reported, we don't need to do anything
if (this.reportedEvents.has(eventId)) {
return;
}
// if we already have a record of this event, use the previously-recorded timestamp
const failure = this.failures.get(eventId);
const ts = failure ? failure.ts : nowTs;
const sender = e.getSender();
const senderDomain = sender?.replace(/^.*?:/, "");
let isFederated: boolean | undefined;
if (this.userDomain !== undefined && senderDomain !== undefined) {
isFederated = this.userDomain !== senderDomain;
}
const wasVisibleToUser = this.visibleEvents.has(eventId);
this.failures.set(
eventId,
new DecryptionFailure(eventId, errCode, ts, isFederated, wasVisibleToUser, this.userTrustsOwnIdentity),
);
}
public addVisibleEvent(e: MatrixEvent): void {
const eventId = e.getId()!;
if (this.trackedEvents.has(eventId)) {
// if it's already reported, we don't need to do anything
if (this.reportedEvents.has(eventId)) {
return;
}
// if we've already marked the event as a failure, mark it as visible
// in the failure object
const failure = this.failures.get(eventId);
if (failure) {
failure.wasVisibleToUser = true;
}
this.visibleEvents.add(eventId);
if (this.failures.has(eventId) && !this.visibleFailures.has(eventId)) {
this.visibleFailures.set(eventId, this.failures.get(eventId)!);
}
public removeDecryptionFailuresForEvent(e: MatrixEvent, nowTs: number): void {
const eventId = e.getId()!;
const failure = this.failures.get(eventId);
if (failure) {
this.failures.delete(eventId);
const timeToDecryptMillis = nowTs - failure.ts;
if (timeToDecryptMillis < DecryptionFailureTracker.GRACE_PERIOD_MS) {
// the event decrypted on time, so we don't need to report it
return;
} else if (timeToDecryptMillis <= DecryptionFailureTracker.MAXIMUM_LATE_DECRYPTION_PERIOD) {
// The event is a late decryption, so store the time it took.
// If the time to decrypt is longer than
// MAXIMUM_LATE_DECRYPTION_PERIOD, we consider the event as
// undecryptable, and leave timeToDecryptMillis undefined
failure.timeToDecryptMillis = timeToDecryptMillis;
}
this.reportFailure(failure);
}
}
public addDecryptionFailure(failure: DecryptionFailure): void {
const eventId = failure.failedEventId;
if (this.trackedEvents.has(eventId)) {
private async handleKeysChanged(client: MatrixClient): Promise<void> {
if (this.checkingVerificationStatus) {
// Flag that we'll need to do another check once the current check completes.
this.retryVerificationStatus = true;
return;
}
this.failures.set(eventId, failure);
if (this.visibleEvents.has(eventId) && !this.visibleFailures.has(eventId)) {
this.visibleFailures.set(eventId, failure);
this.checkingVerificationStatus = true;
try {
do {
this.retryVerificationStatus = false;
this.userTrustsOwnIdentity = (
await client.getCrypto()!.getUserVerificationStatus(client.getUserId()!)
).isCrossSigningVerified();
} while (this.retryVerificationStatus);
} finally {
this.checkingVerificationStatus = false;
}
}
public removeDecryptionFailuresForEvent(e: MatrixEvent): void {
const eventId = e.getId()!;
this.failures.delete(eventId);
this.visibleFailures.delete(eventId);
}
/**
* Start checking for and tracking failures.
*/
public start(): void {
public async start(client: MatrixClient): Promise<void> {
this.loadReportedEvents();
await this.calculateClientProperties(client);
this.registerHandlers(client);
this.checkInterval = window.setInterval(
() => this.checkFailures(Date.now()),
DecryptionFailureTracker.CHECK_INTERVAL_MS,
);
}
this.trackInterval = window.setInterval(() => this.trackFailures(), DecryptionFailureTracker.TRACK_INTERVAL_MS);
private async calculateClientProperties(client: MatrixClient): Promise<void> {
const baseProperties: ErrorProperties = {};
this.baseProperties = baseProperties;
this.userDomain = client.getDomain() ?? undefined;
if (this.userDomain === "matrix.org") {
baseProperties.isMatrixDotOrg = true;
} else if (this.userDomain !== undefined) {
baseProperties.isMatrixDotOrg = false;
}
const crypto = client.getCrypto();
if (crypto) {
const version = crypto.getVersion();
if (version.startsWith("Rust SDK")) {
baseProperties.cryptoSDK = "Rust";
} else {
baseProperties.cryptoSDK = "Legacy";
}
this.userTrustsOwnIdentity = (
await crypto.getUserVerificationStatus(client.getUserId()!)
).isCrossSigningVerified();
}
}
private registerHandlers(client: MatrixClient): void {
// After the client attempts to decrypt an event, we examine it to see
// if it needs to be reported.
const decryptedHandler = (e: MatrixEvent): void => this.eventDecrypted(e, Date.now());
// When our keys change, we check if the cross-signing keys are now trusted.
const keysChangedHandler = (): void => {
this.handleKeysChanged(client).catch((e) => {
console.log("Error handling KeysChanged event", e);
});
};
// When logging out, remove our handlers and destroy state
const loggedOutHandler = (): void => {
client.removeListener(MatrixEventEvent.Decrypted, decryptedHandler);
client.removeListener(CryptoEvent.KeysChanged, keysChangedHandler);
client.removeListener(HttpApiEvent.SessionLoggedOut, loggedOutHandler);
this.stop();
};
client.on(MatrixEventEvent.Decrypted, decryptedHandler);
client.on(CryptoEvent.KeysChanged, keysChangedHandler);
client.on(HttpApiEvent.SessionLoggedOut, loggedOutHandler);
}
/**
* Clear state and stop checking for and tracking failures.
*/
public stop(): void {
private stop(): void {
if (this.checkInterval) clearInterval(this.checkInterval);
if (this.trackInterval) clearInterval(this.trackInterval);
this.userTrustsOwnIdentity = undefined;
this.failures = new Map();
this.visibleEvents = new Set();
this.visibleFailures = new Map();
this.failureCounts = new Map();
}
/**
* Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be
* tracked. Only mark one failure per event ID.
* Mark failures as undecryptable or late. Only mark one failure per event ID.
*
* @param {number} nowTs the timestamp that represents the time now.
*/
public checkFailures(nowTs: number): void {
const failuresGivenGrace: Set<DecryptionFailure> = new Set();
const failuresNotReady: Map<string, DecryptionFailure> = new Map();
for (const [eventId, failure] of this.visibleFailures) {
if (nowTs > failure.ts + DecryptionFailureTracker.GRACE_PERIOD_MS) {
failuresGivenGrace.add(failure);
this.trackedEvents.add(eventId);
for (const [eventId, failure] of this.failures) {
if (
failure.timeToDecryptMillis !== undefined ||
nowTs > failure.ts + DecryptionFailureTracker.MAXIMUM_LATE_DECRYPTION_PERIOD
) {
// we report failures under two conditions:
// - if `timeToDecryptMillis` is set, we successfully decrypted
// the event, but we got the key late. We report it so that we
// have the late decrytion stats.
// - we haven't decrypted yet and it's past the time for it to be
// considered a "late" decryption, so we count it as
// undecryptable.
this.reportFailure(failure);
} else {
// the event isn't old enough, so we still need to keep track of it
failuresNotReady.set(eventId, failure);
}
}
this.visibleFailures = failuresNotReady;
this.failures = failuresNotReady;
// Commented out for now for expediency, we need to consider unbound nature of storing
// this in localStorage
// this.saveTrackedEvents();
this.aggregateFailures(failuresGivenGrace);
}
private aggregateFailures(failures: Set<DecryptionFailure>): void {
for (const failure of failures) {
const errorCode = failure.errorCode;
this.failureCounts.set(errorCode, (this.failureCounts.get(errorCode) ?? 0) + 1);
}
this.saveReportedEvents();
}
/**
* If there are failures that should be tracked, call the given trackDecryptionFailure
* function with the number of failures that should be tracked.
* function with the failures that should be tracked.
*/
public trackFailures(): void {
for (const [errorCode, count] of this.failureCounts.entries()) {
if (count > 0) {
private reportFailure(failure: DecryptionFailure): void {
const errorCode = failure.errorCode;
const trackedErrorCode = this.errorCodeMapFn(errorCode);
const properties: ErrorProperties = {
timeToDecryptMillis: failure.timeToDecryptMillis ?? -1,
wasVisibleToUser: failure.wasVisibleToUser,
};
if (failure.isFederated !== undefined) {
properties.isFederated = failure.isFederated;
}
if (failure.userTrustsOwnIdentity !== undefined) {
properties.userTrustsOwnIdentity = failure.userTrustsOwnIdentity;
}
if (this.baseProperties) {
Object.assign(properties, this.baseProperties);
}
this.fn(trackedErrorCode, errorCode, properties);
this.fn(count, trackedErrorCode, errorCode);
this.failureCounts.set(errorCode, 0);
}
}
this.reportedEvents.add(failure.failedEventId);
// once we've added it to reportedEvents, we won't check
// visibleEvents for it any more
this.visibleEvents.delete(failure.failedEventId);
}
}

View file

@ -26,7 +26,9 @@ import {
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import { PosthogAnalytics } from "./PosthogAnalytics";
import dis from "./dispatcher/dispatcher";
import {
hideToast as hideBulkUnverifiedSessionsToast,
@ -79,6 +81,10 @@ export default class DeviceListener {
private enableBulkUnverifiedSessionsReminder = true;
private deviceClientInformationSettingWatcherRef: string | undefined;
// Remember the current analytics state to avoid sending the same event multiple times.
private analyticsVerificationState?: string;
private analyticsRecoveryState?: string;
public static sharedInstance(): DeviceListener {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
return window.mxDeviceListener;
@ -301,6 +307,7 @@ export default class DeviceListener {
const crossSigningReady = await crypto.isCrossSigningReady();
const secretStorageReady = await crypto.isSecretStorageReady();
const allSystemsReady = crossSigningReady && secretStorageReady;
await this.reportCryptoSessionStateToAnalytics(cli);
if (this.dismissedThisDeviceToast || allSystemsReady) {
hideSetupEncryptionToast();
@ -407,6 +414,70 @@ export default class DeviceListener {
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
}
/**
* Reports current recovery state to analytics.
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
* @param cli - the matrix client
* @private
*/
private async reportCryptoSessionStateToAnalytics(cli: MatrixClient): Promise<void> {
const crypto = cli.getCrypto()!;
const secretStorageReady = await crypto.isSecretStorageReady();
const crossSigningStatus = await crypto.getCrossSigningStatus();
const backupInfo = await this.getKeyBackupInfo();
const is4SEnabled = (await cli.secretStorage.getDefaultKeyId()) != null;
const deviceVerificationStatus = await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.getDeviceId()!);
const verificationState =
deviceVerificationStatus?.signedByOwner && deviceVerificationStatus?.crossSigningVerified
? "Verified"
: "NotVerified";
let recoveryState: "Disabled" | "Enabled" | "Incomplete";
if (!is4SEnabled) {
recoveryState = "Disabled";
} else {
const allCrossSigningSecretsCached =
crossSigningStatus.privateKeysCachedLocally.masterKey &&
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
if (backupInfo != null) {
// There is a backup. Check that all secrets are stored in 4S and known locally.
// If they are not, recovery is incomplete.
const backupPrivateKeyIsInCache = (await crypto.getSessionBackupPrivateKey()) != null;
if (secretStorageReady && allCrossSigningSecretsCached && backupPrivateKeyIsInCache) {
recoveryState = "Enabled";
} else {
recoveryState = "Incomplete";
}
} else {
// No backup. Just consider cross-signing secrets.
if (secretStorageReady && allCrossSigningSecretsCached) {
recoveryState = "Enabled";
} else {
recoveryState = "Incomplete";
}
}
}
if (this.analyticsVerificationState === verificationState && this.analyticsRecoveryState === recoveryState) {
// No changes, no need to send the event nor update the user properties
return;
}
this.analyticsRecoveryState = recoveryState;
this.analyticsVerificationState = verificationState;
// Update user properties
PosthogAnalytics.instance.setProperty("recoveryState", recoveryState);
PosthogAnalytics.instance.setProperty("verificationState", verificationState);
PosthogAnalytics.instance.trackEvent<CryptoSessionStateChange>({
eventName: "CryptoSessionState",
verificationState: verificationState,
recoveryState: recoveryState,
});
}
/**
* Check if key backup is enabled, and if not, raise an `Action.ReportKeyBackupNotEnabled` event (which will
* trigger an auto-rageshake).

View file

@ -78,15 +78,6 @@ export interface IMatrixClientPeg {
*/
opts: IStartClientOpts;
/**
* Return the server name of the user's homeserver
* Throws an error if unable to deduce the homeserver name
* (e.g. if the user is not logged in)
*
* @returns {string} The homeserver name, if present.
*/
getHomeserverName(): string;
/**
* Get the current MatrixClient, if any
*/
@ -384,14 +375,6 @@ class MatrixClientPegClass implements IMatrixClientPeg {
logger.log(`MatrixClientPeg: MatrixClient started`);
}
public getHomeserverName(): string {
const matches = /^@[^:]+:(.+)$/.exec(this.safeGet().getSafeUserId());
if (matches === null || matches.length < 1) {
throw new Error("Failed to derive homeserver name from user ID!");
}
return matches[1];
}
private namesToRoomName(names: string[], count: number): string | undefined {
const countWithoutMe = count - 1;
if (!names.length) {

View file

@ -65,10 +65,12 @@ interface IOptions<C extends ComponentType> {
export enum ModalManagerEvent {
Opened = "opened",
Closed = "closed",
}
type HandlerMap = {
[ModalManagerEvent.Opened]: () => void;
[ModalManagerEvent.Closed]: () => void;
};
export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMap> {
@ -232,6 +234,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
}
this.reRender();
this.emitClosed();
},
deferred.promise,
];
@ -328,6 +331,14 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
}
}
/**
* Emit the closed event
* @private
*/
private emitClosed(): void {
this.emit(ModalManagerEvent.Closed);
}
private onBackgroundClick = (): void => {
const modal = this.getCurrentModal();
if (!modal) {

View file

@ -97,11 +97,7 @@ export default class PasswordReset {
// Note: Though this sounds like a login type for identity servers only, it
// has a dual purpose of being used for homeservers too.
type: "m.login.email.identity",
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
},
this.password,
this.logoutDevices,

View file

@ -14,13 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {
DeviceVerificationStatus,
ICryptoCallbacks,
MatrixClient,
encodeBase64,
SecretStorage,
} from "matrix-js-sdk/src/matrix";
import { Crypto, ICryptoCallbacks, MatrixClient, encodeBase64, SecretStorage } from "matrix-js-sdk/src/matrix";
import { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase";
import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey";
import { logger } from "matrix-js-sdk/src/logger";
@ -249,7 +243,7 @@ async function onSecretRequested(
deviceId: string,
requestId: string,
name: string,
deviceTrust: DeviceVerificationStatus,
deviceTrust: Crypto.DeviceVerificationStatus,
): Promise<string | undefined> {
logger.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.safeGet();

View file

@ -359,10 +359,14 @@ export class SlidingSyncManager {
let proxyUrl: string | undefined;
try {
const clientWellKnown = await AutoDiscovery.findClientConfig(client.getDomain()!);
const clientDomain = await client.getDomain();
if (clientDomain === null) {
throw new RangeError("Homeserver domain is null");
}
const clientWellKnown = await AutoDiscovery.findClientConfig(clientDomain);
proxyUrl = clientWellKnown?.["org.matrix.msc3575.proxy"]?.url;
} catch (e) {
// client.getDomain() is invalid, `AutoDiscovery.findClientConfig` has thrown
// Either client.getDomain() is null so we've shorted out, or is invalid so `AutoDiscovery.findClientConfig` has thrown
}
if (proxyUrl != undefined) {

View file

@ -203,6 +203,7 @@ export const KEY_ICON: Record<string, string> = {
if (IS_MAC) {
KEY_ICON[Key.META] = "⌘";
KEY_ICON[Key.ALT] = "⌥";
KEY_ICON[Key.SHIFT] = "⇧";
}
export const CATEGORIES: Record<CategoryName, ICategory> = {

View file

@ -390,4 +390,3 @@ export const useRovingTabIndex = <T extends HTMLElement>(
// re-export the semantic helper components for simplicity
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton";

View file

@ -18,9 +18,9 @@ limitations under the License.
import React, { ComponentProps, forwardRef, Ref } from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleTooltipButton<T>> & {
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleButton<T>> & {
// whether the context menu is currently open
isExpanded: boolean;
};
@ -31,17 +31,17 @@ export const ContextMenuTooltipButton = forwardRef(function <T extends keyof JSX
ref: Ref<HTMLElement>,
) {
return (
<AccessibleTooltipButton
<AccessibleButton
{...props}
element={element as keyof JSX.IntrinsicElements}
onClick={onClick}
onContextMenu={onContextMenu ?? onClick ?? undefined}
aria-haspopup={true}
aria-expanded={isExpanded}
forceHide={isExpanded}
disableTooltip={isExpanded}
ref={ref}
>
{children}
</AccessibleTooltipButton>
</AccessibleButton>
);
});

View file

@ -1,47 +0,0 @@
/*
Copyright 2020 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, { ComponentProps } from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import { Ref } from "./types";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
type Props<T extends keyof JSX.IntrinsicElements> = Omit<ComponentProps<typeof AccessibleButton<T>>, "tabIndex"> & {
inputRef?: Ref;
};
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleTooltipButton = <T extends keyof JSX.IntrinsicElements>({
inputRef,
onFocus,
element,
...props
}: Props<T>): JSX.Element => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return (
<AccessibleButton
{...props}
element={element as keyof JSX.IntrinsicElements}
onFocus={(event: React.FocusEvent) => {
onFocusInternal();
onFocus?.(event);
}}
ref={ref}
tabIndex={isActive ? 0 : -1}
/>
);
};

View file

@ -316,9 +316,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
type: "m.id.user",
user: MatrixClientPeg.safeGet().getSafeUserId(),
},
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
user: MatrixClientPeg.safeGet().getSafeUserId(),
password: this.state.accountPassword,
});
} else {

View file

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IEncryptedFile, MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { EncryptedFile } from "matrix-js-sdk/src/types";
import { SimpleObservable } from "matrix-widget-api";
import { uploadFile } from "../ContentMessages";
@ -26,7 +27,7 @@ import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecordi
export interface IUpload {
mxc?: string; // for unencrypted uploads
encrypted?: IEncryptedFile;
encrypted?: EncryptedFile;
}
/**

View file

@ -21,11 +21,10 @@ import {
EventType,
HttpApiEvent,
MatrixClient,
MatrixEventEvent,
MatrixEvent,
RoomType,
SyncStateData,
SyncState,
SyncStateData,
TimelineEvents,
} from "matrix-js-sdk/src/matrix";
import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
@ -129,7 +128,7 @@ import { TimelineRenderingType } from "../../contexts/RoomContext";
import { UseCaseSelection } from "../views/elements/UseCaseSelection";
import { ValidatedServerConfig } from "../../utils/ValidatedServerConfig";
import { isLocalRoom } from "../../utils/localRoom/isLocalRoom";
import { SdkContextClass, SDKContext } from "../../contexts/SDKContext";
import { SDKContext, SdkContextClass } from "../../contexts/SDKContext";
import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSettings";
import { cleanUpBroadcasts, VoiceBroadcastResumer } from "../../voice-broadcast";
import GenericToast from "../views/toasts/GenericToast";
@ -1586,17 +1585,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
});
const dft = DecryptionFailureTracker.instance;
// Shelved for later date when we have time to think about persisting history of
// tracked events across sessions.
// dft.loadTrackedEventHashMap();
dft.start();
// When logging out, stop tracking failures and destroy state
cli.on(HttpApiEvent.SessionLoggedOut, () => dft.stop());
cli.on(MatrixEventEvent.Decrypted, (e) => dft.eventDecrypted(e));
DecryptionFailureTracker.instance
.start(cli)
.catch((e) => logger.error("Unable to start DecryptionFailureTracker", e));
cli.on(ClientEvent.Room, (room) => {
if (cli.isCryptoEnabled()) {

View file

@ -253,6 +253,7 @@ const SpaceLanding: React.FC<{ space: Room }> = ({ space }) => {
showSpaceSettings(space);
}}
title={_t("common|settings")}
placement="bottom"
/>
);
}

View file

@ -30,7 +30,7 @@ import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme";
import { RovingAccessibleTooltipButton } from "../../accessibility/RovingTabIndex";
import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import { getHomePageUrl } from "../../utils/pages";
@ -426,7 +426,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
</span>
</div>
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={
@ -441,7 +441,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
alt=""
width={16}
/>
</RovingAccessibleTooltipButton>
</RovingAccessibleButton>
</div>
{topSection}
{primaryOptionList}

View file

@ -26,10 +26,8 @@ import SettingsStore from "../../../settings/SettingsStore";
import { LocalisedPolicy, Policies } from "../../../Terms";
import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier";
import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import Field from "../elements/Field";
import Spinner from "../elements/Spinner";
import { Alignment } from "../elements/Tooltip";
import CaptchaForm from "./CaptchaForm";
/* This file contains a collection of components which are used by the
@ -121,9 +119,6 @@ export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswor
this.props.submitAuthDict({
type: AuthType.Password,
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
user: this.props.matrixClient.credentials.userId ?? undefined,
identifier: {
type: "m.id.user",
user: this.props.matrixClient.credentials.userId,
@ -504,15 +499,16 @@ export class EmailIdentityAuthEntry extends React.Component<
{},
{
a: (text: string) => (
<AccessibleTooltipButton
<AccessibleButton
kind="link_inline"
title={
this.state.requested ? _t("auth|uia|email_resent") : _t("action|resend")
}
alignment={Alignment.Right}
onHideTooltip={
onTooltipOpenChange={
this.state.requested
? () => this.setState({ requested: false })
? (open) => {
if (!open) this.setState({ requested: false });
}
: undefined
}
onClick={async (): Promise<void> => {
@ -527,7 +523,7 @@ export class EmailIdentityAuthEntry extends React.Component<
}}
>
{text}
</AccessibleTooltipButton>
</AccessibleButton>
),
},
)}
@ -634,11 +630,7 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
};
this.props.submitAuthDict({
type: AuthType.Msisdn,
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
});
} else {
this.setState({

View file

@ -27,7 +27,6 @@ import SdkConfig from "../../../SdkConfig";
import SettingsFlag from "../elements/SettingsFlag";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import InlineSpinner from "../elements/InlineSpinner";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { shouldShowFeedback } from "../../../utils/Feedback";
// XXX: Keep this around for re-use in future Betas
@ -50,19 +49,15 @@ export const BetaPill: React.FC<IBetaPillProps> = ({
}) => {
if (onClick) {
return (
<AccessibleTooltipButton
<AccessibleButton
className="mx_BetaCard_betaPill"
title={`${tooltipTitle} ${tooltipCaption}`}
tooltip={
<div>
<div className="mx_Tooltip_title">{tooltipTitle}</div>
<div className="mx_Tooltip_sub">{tooltipCaption}</div>
</div>
}
aria-label={`${tooltipTitle} ${tooltipCaption}`}
title={tooltipTitle}
caption={tooltipCaption}
onClick={onClick}
>
{_t("common|beta")}
</AccessibleTooltipButton>
</AccessibleButton>
);
}

View file

@ -436,7 +436,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
</summary>
<LabelledToggleSwitch
label={_t("create_room|unfederated", {
serverName: MatrixClientPeg.getHomeserverName(),
serverName: MatrixClientPeg.safeGet().getDomain(),
})}
onChange={this.onNoFederateChange}
value={this.state.noFederate}

View file

@ -95,7 +95,7 @@ export default class ServerOfflineDialog extends React.PureComponent<IProps> {
timeline = [<div key={1}>{_t("server_offline|empty_timeline")}</div>];
}
const serverName = MatrixClientPeg.getHomeserverName();
const serverName = MatrixClientPeg.safeGet().getDomain();
return (
<BaseDialog
title={_t("server_offline|title")}

View file

@ -38,7 +38,7 @@ export async function getServerVersionFromFederationApi(client: MatrixClient): P
let baseUrl = client.getHomeserverUrl();
try {
const hsName = MatrixClientPeg.getHomeserverName();
const hsName = MatrixClientPeg.safeGet().getDomain();
// We don't use the js-sdk Autodiscovery module here as it only support client well-known, not server ones.
const response = await fetch(`https://${hsName}/.well-known/matrix/server`);
const json = await response.json();

View file

@ -101,9 +101,6 @@ export default class CreateCrossSigningDialog extends React.PureComponent<IProps
type: "m.id.user",
user: MatrixClientPeg.safeGet().getUserId(),
},
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
user: MatrixClientPeg.safeGet().getUserId(),
password: this.state.accountPassword,
});
} else if (this.props.tokenLogin) {

View file

@ -118,7 +118,7 @@ function useServers(): ServerList {
SettingLevel.ACCOUNT,
);
const homeServer = MatrixClientPeg.getHomeserverName();
const homeServer = MatrixClientPeg.safeGet().getDomain()!;
const configServers = new Set<string>(SdkConfig.getObject("room_directory")?.get("servers") ?? []);
removeAll(configServers, homeServer);
// configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.

View file

@ -106,6 +106,11 @@ type Props<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementProps<T> &
* Callback for when the tooltip is opened or closed.
*/
onTooltipOpenChange?: TooltipProps["onOpenChange"];
/**
* Whether the tooltip should be disabled.
*/
disableTooltip?: TooltipProps["disabled"];
};
/**
@ -140,6 +145,7 @@ const AccessibleButton = forwardRef(function <T extends keyof JSX.IntrinsicEleme
caption,
placement = "right",
onTooltipOpenChange,
disableTooltip,
...restProps
}: Props<T>,
ref: Ref<HTMLElement>,
@ -217,6 +223,7 @@ const AccessibleButton = forwardRef(function <T extends keyof JSX.IntrinsicEleme
isTriggerInteractive={true}
placement={placement}
onOpenChange={onTooltipOpenChange}
disabled={disableTooltip}
>
{button}
</Tooltip>

View file

@ -1,118 +0,0 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 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, { SyntheticEvent, FocusEvent, forwardRef, useEffect, Ref, useState, ComponentProps } from "react";
import AccessibleButton from "./AccessibleButton";
import Tooltip, { Alignment } from "./Tooltip";
/**
* Type of props accepted by {@link AccessibleTooltipButton}.
*
* Extends that of {@link AccessibleButton}.
*/
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleButton<T>> & {
/**
* Title to show in the tooltip and use as aria-label
*/
title?: string;
/**
* Tooltip node to show in the tooltip, takes precedence over `title`
*/
tooltip?: React.ReactNode;
/**
* Trigger label to render
*/
label?: string;
/**
* Classname to apply to the tooltip
*/
tooltipClassName?: string;
/**
* Force the tooltip to be hidden
*/
forceHide?: boolean;
/**
* Alignment to render the tooltip with
*/
alignment?: Alignment;
/**
* Function to call when the children are hovered over
*/
onHover?: (hovering: boolean) => void;
/**
* Function to call when the tooltip goes from shown to hidden.
*/
onHideTooltip?(ev: SyntheticEvent): void;
};
/**
* @deprecated use AccessibleButton with `title` and `caption` instead.
*/
const AccessibleTooltipButton = forwardRef(function <T extends keyof JSX.IntrinsicElements>(
{ title, tooltip, children, forceHide, alignment, onHideTooltip, tooltipClassName, element, ...props }: Props<T>,
ref: Ref<HTMLElement>,
) {
const [hover, setHover] = useState(false);
useEffect(() => {
// If forceHide is set then force hover to off to hide the tooltip
if (forceHide && hover) {
setHover(false);
}
}, [forceHide, hover]);
const showTooltip = (): void => {
props.onHover?.(true);
if (forceHide) return;
setHover(true);
};
const hideTooltip = (ev: SyntheticEvent): void => {
props.onHover?.(false);
setHover(false);
onHideTooltip?.(ev);
};
const onFocus = (ev: FocusEvent): void => {
// We only show the tooltip if focus arrived here from some other
// element, to avoid leaving tooltips hanging around when a modal closes
if (ev.relatedTarget) showTooltip();
};
const tip = hover && (title || tooltip) && (
<Tooltip tooltipClassName={tooltipClassName} label={tooltip || title} alignment={alignment} />
);
return (
<AccessibleButton
{...props}
element={element as keyof JSX.IntrinsicElements}
onMouseOver={showTooltip}
onMouseLeave={hideTooltip}
onFocus={onFocus}
onBlur={hideTooltip}
aria-label={title || props["aria-label"]}
ref={ref}
>
{children}
{props.label}
{(tooltip || title) && tip}
</AccessibleButton>
);
});
export default AccessibleTooltipButton;

View file

@ -18,6 +18,7 @@ limitations under the License.
import React from "react";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
@ -29,7 +30,6 @@ import Heading from "../typography/Heading";
import AccessibleButton from "./AccessibleButton";
import { parseUrl } from "../../../utils/UrlUtils";
import { Icon as HelpIcon } from "../../../../res/img/feather-customised/help-circle.svg";
import TooltipTarget from "./TooltipTarget";
interface IProps {
url: string;
@ -99,9 +99,10 @@ export default class AppPermission extends React.Component<IProps, IState> {
<BaseAvatar name={this.props.creatorUserId} size="38px" />
);
const warningTooltipText = (
<div>
{_t("analytics|shared_data_heading")}
const warningTooltip = (
<Tooltip
label={_t("analytics|shared_data_heading")}
caption={
<ul>
<li>{_t("widget|shared_data_name")}</li>
<li>{_t("widget|shared_data_avatar")}</li>
@ -113,17 +114,12 @@ export default class AppPermission extends React.Component<IProps, IState> {
<li>{_t("widget|shared_data_room_id")}</li>
<li>{_t("widget|shared_data_widget_id")}</li>
</ul>
</div>
);
const warningTooltip = (
<TooltipTarget
label={warningTooltipText}
tooltipClassName="mx_Tooltip--appPermission mx_Tooltip--appPermission--dark"
tooltipTargetClassName="mx_TextWithTooltip_target mx_TextWithTooltip_target--helpIcon"
className="mx_TextWithTooltip_tooltip"
}
>
<div className="mx_TextWithTooltip_target mx_TextWithTooltip_target--helpIcon">
<HelpIcon className="mx_Icon mx_Icon_12" />
</TooltipTarget>
</div>
</Tooltip>
);
// Due to i18n limitations, we can't dedupe the code for variables in these two messages.

View file

@ -14,12 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes, RefObject, createRef } from "react";
import React, {
InputHTMLAttributes,
SelectHTMLAttributes,
TextareaHTMLAttributes,
RefObject,
createRef,
KeyboardEvent,
} from "react";
import classNames from "classnames";
import { debounce } from "lodash";
import { IFieldState, IValidationResult } from "./Validation";
import Tooltip from "./Tooltip";
import { Key } from "../../../Keyboard";
// Invoke validation from user input (when typing, etc.) at most once every N ms.
const VALIDATION_THROTTLE_MS = 200;
@ -232,6 +240,18 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
return this.props.inputRef ?? this._inputRef;
}
private onKeyDown = (evt: KeyboardEvent<HTMLDivElement>): void => {
// If the tooltip is displayed to show a feedback and Escape is pressed
// The tooltip is hided
if (this.state.feedbackVisible && evt.key === Key.ESCAPE) {
evt.preventDefault();
evt.stopPropagation();
this.setState({
feedbackVisible: false,
});
}
};
public render(): React.ReactNode {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const {
@ -318,7 +338,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
});
return (
<div className={fieldClasses}>
<div className={fieldClasses} onKeyDown={this.onKeyDown}>
{prefixContainer}
{fieldInput}
<label htmlFor={this.id}>{this.props.label}</label>

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useContext, useRef } from "react";
import React, { useCallback, useContext, useState } from "react";
import { Room, EventType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import { useTopic } from "../../../hooks/room/useTopic";
import { Alignment } from "./Tooltip";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
@ -28,7 +28,6 @@ import InfoDialog from "../dialogs/InfoDialog";
import { useDispatcher } from "../../../hooks/useDispatcher";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton from "./AccessibleButton";
import TooltipTarget from "./TooltipTarget";
import { Linkify, topicToHtml } from "../../../HtmlUtils";
import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
@ -49,10 +48,10 @@ export function onRoomTopicLinkClick(e: React.MouseEvent): void {
export default function RoomTopic({ room, className, ...props }: IProps): JSX.Element {
const client = useContext(MatrixClientContext);
const ref = useRef<HTMLDivElement>(null);
const [disableTooltip, setDisableTooltip] = useState(false);
const topic = useTopic(room);
const body = topicToHtml(topic?.text, topic?.html, ref);
const body = topicToHtml(topic?.text, topic?.html);
const onClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@ -70,14 +69,14 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El
[props],
);
const ignoreHover = (ev: React.MouseEvent): boolean => {
return (ev.target as HTMLElement).tagName.toUpperCase() === "A";
const onHover = (ev: React.MouseEvent | React.FocusEvent): void => {
setDisableTooltip((ev.target as HTMLElement).tagName.toUpperCase() === "A");
};
useDispatcher(dis, (payload) => {
if (payload.action === Action.ShowRoomTopic) {
const canSetTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, client.getSafeUserId());
const body = topicToHtml(topic?.text, topic?.html, ref, true);
const body = topicToHtml(topic?.text, topic?.html, undefined, true);
const modal = Modal.createDialog(InfoDialog, {
title: room.name,
@ -115,18 +114,24 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El
}
});
// Do not render the tooltip if the topic is empty
// We still need to have a div for the header buttons to be displayed correctly
if (!body) return <div className={classNames(className, "mx_RoomTopic")} />;
return (
<TooltipTarget
<Tooltip label={_t("room|read_topic")} disabled={disableTooltip}>
<div
{...props}
ref={ref}
tabIndex={0}
role="button"
onClick={onClick}
dir="auto"
tooltipTargetClassName={classNames(className, "mx_RoomTopic")}
label={_t("room|read_topic")}
alignment={Alignment.Bottom}
ignoreHover={ignoreHover}
className={classNames(className, "mx_RoomTopic")}
onMouseOver={onHover}
onFocus={onHover}
aria-label={_t("room|read_topic")}
>
<Linkify>{body}</Linkify>
</TooltipTarget>
</div>
</Tooltip>
);
}

View file

@ -57,6 +57,9 @@ export interface ITooltipProps {
type State = Partial<Pick<CSSProperties, "display" | "right" | "top" | "transform" | "left">>;
/**
* @deprecated Use [compound tooltip](https://element-hq.github.io/compound-web/?path=/docs/tooltip--docs) instead
*/
export default class Tooltip extends React.PureComponent<ITooltipProps, State> {
private static container: HTMLElement;
private parent: Element | null = null;

View file

@ -1,92 +0,0 @@
/*
Copyright 2021 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, { forwardRef, HTMLAttributes, useRef } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import useFocus from "../../../hooks/useFocus";
import useHover from "../../../hooks/useHover";
import Tooltip, { ITooltipProps } from "./Tooltip";
interface IProps
extends HTMLAttributes<HTMLSpanElement>,
Omit<ITooltipProps, "visible" | "tabIndex" | "aria-describedby"> {
tooltipTargetClassName?: string;
ignoreHover?: (ev: React.MouseEvent) => boolean;
}
/**
* Generic tooltip target element that handles tooltip visibility state
* and displays children
*/
const TooltipTarget = forwardRef<HTMLDivElement, IProps>(
(
{
children,
tooltipTargetClassName,
// tooltip pass through props
className,
id,
label,
alignment,
tooltipClassName,
maxParentWidth,
ignoreHover,
...rest
},
ref,
) => {
const idRef = useRef("mx_TooltipTarget_" + randomString(8));
// Use generated ID if one is not passed
if (id === undefined) {
id = idRef.current;
}
const [isFocused, focusProps] = useFocus();
const [isHovering, hoverProps] = useHover(ignoreHover || (() => false));
// No need to fill up the DOM with hidden tooltip elements. Only add the
// tooltip when we're hovering over the item (performance)
const tooltip = (isFocused || isHovering) && (
<Tooltip
id={id}
className={className}
tooltipClassName={tooltipClassName}
label={label}
alignment={alignment}
visible={isFocused || isHovering}
maxParentWidth={maxParentWidth}
/>
);
return (
<div
{...hoverProps}
{...focusProps}
tabIndex={0}
aria-describedby={id}
className={tooltipTargetClassName}
{...rest}
ref={ref}
>
{children}
{tooltip}
</div>
);
},
);
export default TooltipTarget;

View file

@ -20,7 +20,7 @@ import classNames from "classnames";
import { Icon as DownloadIcon } from "../../../../res/img/download.svg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import { FileDownloader } from "../../../utils/FileDownloader";
@ -93,7 +93,7 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
});
return (
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className={classes}
title={spinner ? _t(this.state.tooltip) : _t("action|download")}
onClick={this.onDownloadClick}
@ -102,7 +102,7 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
>
<DownloadIcon />
{spinner}
</RovingAccessibleTooltipButton>
</RovingAccessibleButton>
);
}
}

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from "react";
import { MatrixEvent, ClientEvent, ClientEventHandlerMap } from "matrix-js-sdk/src/matrix";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
@ -27,8 +28,6 @@ import {
isSelfLocation,
} from "../../../utils/location";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import TooltipTarget from "../elements/TooltipTarget";
import { Alignment } from "../elements/Tooltip";
import { SmartMarker, Map, LocationViewDialog } from "../location";
import { IBodyProps } from "./IBodyProps";
import { createReconnectedListener } from "../../../utils/connection";
@ -126,7 +125,7 @@ export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent; error:
interface LocationBodyContentProps {
mxEvent: MatrixEvent;
mapId: string;
tooltip?: string;
tooltip: string;
onError: (error: Error) => void;
onClick?: () => void;
}
@ -156,13 +155,9 @@ export const LocationBodyContent: React.FC<LocationBodyContentProps> = ({
return (
<div className="mx_MLocationBody">
{tooltip ? (
<TooltipTarget label={tooltip} alignment={Alignment.InnerBottom} maxParentWidth={450}>
{mapElement}
</TooltipTarget>
) : (
mapElement
)}
<Tooltip label={tooltip}>
<div className="mx_MLocationBody_map">{mapElement}</div>
</Tooltip>
</div>
);
};

View file

@ -43,7 +43,7 @@ import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } fr
import { isContentActionable, canEditContent, editEvent, canCancel } from "../../../utils/EventUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import { RovingAccessibleButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -234,7 +234,7 @@ const ReplyInThreadButton: React.FC<IReplyInThreadButton> = ({ mxEvent }) => {
const title = !hasARelation ? _t("action|reply_in_thread") : _t("threads|error_start_thread_existing_relation");
return (
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton mx_MessageActionBar_threadButton"
disabled={hasARelation}
title={title}
@ -243,7 +243,7 @@ const ReplyInThreadButton: React.FC<IReplyInThreadButton> = ({ mxEvent }) => {
placement="left"
>
<ThreadIcon />
</RovingAccessibleTooltipButton>
</RovingAccessibleButton>
);
};
@ -387,7 +387,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
const toolbarOpts: JSX.Element[] = [];
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
toolbarOpts.push(
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|edit")}
onClick={this.onEditClick}
@ -396,12 +396,12 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
placement="left"
>
<EditIcon />
</RovingAccessibleTooltipButton>,
</RovingAccessibleButton>,
);
}
const cancelSendingButton = (
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|delete")}
onClick={this.onCancelClick}
@ -410,7 +410,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
placement="left"
>
<TrashcanIcon />
</RovingAccessibleTooltipButton>
</RovingAccessibleButton>
);
const threadTooltipButton = <ReplyInThreadButton mxEvent={this.props.mxEvent} key="reply_thread" />;
@ -427,7 +427,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
toolbarOpts.splice(
0,
0,
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|retry")}
onClick={this.onResendClick}
@ -436,7 +436,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
placement="left"
>
<ResendIcon />
</RovingAccessibleTooltipButton>,
</RovingAccessibleButton>,
);
// The delete button should appear last, so we can just drop it at the end
@ -454,7 +454,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
toolbarOpts.splice(
0,
0,
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|reply")}
onClick={this.onReplyClick}
@ -463,7 +463,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
placement="left"
>
<ReplyIcon />
</RovingAccessibleTooltipButton>,
</RovingAccessibleButton>,
);
}
// We hide the react button in search results as we don't show reactions in results
@ -511,7 +511,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
});
toolbarOpts.push(
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className={expandClassName}
title={
this.props.isQuoteExpanded
@ -524,7 +524,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
placement="left"
>
{this.props.isQuoteExpanded ? <CollapseMessageIcon /> : <ExpandMessageIcon />}
</RovingAccessibleTooltipButton>,
</RovingAccessibleButton>,
);
}

View file

@ -44,20 +44,10 @@ export interface IProps {
customReactionImagesEnabled?: boolean;
}
interface IState {
tooltipRendered: boolean;
tooltipVisible: boolean;
}
export default class ReactionsRowButton extends React.PureComponent<IProps, IState> {
export default class ReactionsRowButton extends React.PureComponent<IProps> {
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
public state = {
tooltipRendered: false,
tooltipVisible: false,
};
public onClick = (): void => {
const { mxEvent, myReactionEvent, content } = this.props;
if (myReactionEvent) {
@ -74,21 +64,6 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
}
};
public onMouseOver = (): void => {
this.setState({
// To avoid littering the DOM with a tooltip for every reaction,
// only render it on first use.
tooltipRendered: true,
tooltipVisible: true,
});
};
public onMouseLeave = (): void => {
this.setState({
tooltipVisible: false,
});
};
public render(): React.ReactNode {
const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props;
@ -97,19 +72,6 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
mx_ReactionsRowButton_selected: !!myReactionEvent,
});
let tooltip: JSX.Element | undefined;
if (this.state.tooltipRendered) {
tooltip = (
<ReactionsRowButtonTooltip
mxEvent={this.props.mxEvent}
content={content}
reactionEvents={reactionEvents}
visible={this.state.tooltipVisible}
customReactionImagesEnabled={this.props.customReactionImagesEnabled}
/>
);
}
const room = this.context.getRoom(mxEvent.getRoomId());
let label: string | undefined;
let customReactionName: string | undefined;
@ -156,20 +118,24 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
}
return (
<ReactionsRowButtonTooltip
mxEvent={this.props.mxEvent}
content={content}
reactionEvents={reactionEvents}
customReactionImagesEnabled={this.props.customReactionImagesEnabled}
>
<AccessibleButton
className={classes}
aria-label={label}
onClick={this.onClick}
disabled={this.props.disabled}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
{reactionContent}
<span className="mx_ReactionsRowButton_count" aria-hidden="true">
{count}
</span>
{tooltip}
</AccessibleButton>
</ReactionsRowButtonTooltip>
);
}
}

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { PropsWithChildren } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web";
import { unicodeToShortcode } from "../../../HtmlUtils";
import { _t } from "../../../languageHandler";
import { formatList } from "../../../utils/FormattingUtils";
import Tooltip from "../elements/Tooltip";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { REACTION_SHORTCODE_KEY } from "./ReactionsRow";
interface IProps {
@ -30,20 +30,18 @@ interface IProps {
content: string;
// A list of Matrix reaction events for this key
reactionEvents: MatrixEvent[];
visible: boolean;
// Whether to render custom image reactions
customReactionImagesEnabled?: boolean;
}
export default class ReactionsRowButtonTooltip extends React.PureComponent<IProps> {
export default class ReactionsRowButtonTooltip extends React.PureComponent<PropsWithChildren<IProps>> {
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
public render(): React.ReactNode {
const { content, reactionEvents, mxEvent, visible } = this.props;
const { content, reactionEvents, mxEvent, children } = this.props;
const room = this.context.getRoom(mxEvent.getRoomId());
let tooltipLabel: JSX.Element | undefined;
if (room) {
const senders: string[] = [];
let customReactionName: string | undefined;
@ -57,34 +55,16 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent<IProp
undefined;
}
const shortName = unicodeToShortcode(content) || customReactionName;
tooltipLabel = (
<div>
{_t(
"timeline|reactions|tooltip",
{
shortName,
},
{
reactors: () => {
return <div className="mx_Tooltip_title">{formatList(senders, 6)}</div>;
},
reactedWith: (sub) => {
if (!shortName) {
return null;
}
return <div className="mx_Tooltip_sub">{sub}</div>;
},
},
)}
</div>
const formattedSenders = formatList(senders, 6);
const caption = shortName ? _t("timeline|reactions|tooltip_caption", { shortName }) : undefined;
return (
<Tooltip label={formattedSenders} caption={caption} placement="right">
{children}
</Tooltip>
);
}
let tooltip: JSX.Element | undefined;
if (tooltipLabel) {
tooltip = <Tooltip visible={visible} label={tooltipLabel} />;
}
return tooltip;
return children;
}
}

View file

@ -26,7 +26,7 @@ import WidgetStore from "../../../stores/WidgetStore";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import { Icon as BackIcon } from "../../../../res/img/element-icons/back.svg";
import { Icon as HangupIcon } from "../../../../res/img/element-icons/call/hangup.svg";
import { _t } from "../../../languageHandler";
@ -125,14 +125,14 @@ export const WidgetPip: FC<Props> = ({ widgetId, room, viewingRoom, onStartMovin
</Toolbar>
{(call !== null || WidgetType.JITSI.matches(widget?.type)) && (
<Toolbar className="mx_WidgetPip_footer">
<RovingAccessibleTooltipButton
<RovingAccessibleButton
onClick={onLeaveClick}
title={_t("action|leave")}
aria-label={_t("action|leave")}
placement="top"
>
<HangupIcon className="mx_Icon mx_Icon_24" />
</RovingAccessibleTooltipButton>
</RovingAccessibleButton>
</Toolbar>
)}
</div>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { VerificationPhase, VerificationRequest, VerificationRequestEvent } from "matrix-js-sdk/src/crypto-api";
import { RoomMember, User } from "matrix-js-sdk/src/matrix";
@ -69,13 +69,17 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
awaitPromise();
}
}, [verificationRequestPromise]);
// Use a ref to track whether we are already showing the mismatch modal as state may not update fast enough
// if two change events are fired in quick succession like can happen with rust crypto.
const isShowingMismatchModal = useRef(false);
const changeHandler = useCallback(() => {
// handle transitions -> cancelled for mismatches which fire a modal instead of showing a card
if (
request &&
request.phase === VerificationPhase.Cancelled &&
!isShowingMismatchModal.current &&
request?.phase === VerificationPhase.Cancelled &&
MISMATCHES.includes(request.cancellationCode ?? "")
) {
isShowingMismatchModal.current = true;
Modal.createDialog(ErrorDialog, {
headerImage: require("../../../../res/img/e2e/warning-deprecated.svg").default,
title: _t("encryption|messages_not_secure|title"),

View file

@ -43,7 +43,6 @@ import DMRoomMap from "../../../utils/DMRoomMap";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import SdkConfig from "../../../SdkConfig";
import MultiInviter from "../../../utils/MultiInviter";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import E2EIcon from "../rooms/E2EIcon";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { textualPowerLevel } from "../../../Roles";
@ -1413,8 +1412,7 @@ const BasicUserInfo: React.FC<{
// We don't need a perfect check here, just something to pass as "probably not our homeserver". If
// someone does figure out how to bypass this check the worst that happens is an error.
// FIXME this should be using cli instead of MatrixClientPeg.matrixClient
if (isSynapseAdmin && member.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) {
if (isSynapseAdmin && member.userId.endsWith(`:${cli.getDomain()}`)) {
synapseDeactivateButton = (
<AccessibleButton
kind="link"

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -21,11 +21,9 @@ import { EventType } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Field from "../elements/Field";
import { mediaFromMxc } from "../../../customisations/Media";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AvatarSetting from "../settings/AvatarSetting";
import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
interface IProps {
roomId: string;
@ -35,8 +33,9 @@ interface IState {
originalDisplayName: string;
displayName: string;
originalAvatarUrl: string | null;
avatarUrl: string | null;
avatarFile: File | null;
// If true, the user has indicated that they wish to remove the avatar and this should happen on save.
avatarRemovalPending: boolean;
originalTopic: string;
topic: string;
profileFieldsTouched: Record<string, boolean>;
@ -57,8 +56,7 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
if (!room) throw new Error(`Expected a room for ID: ${props.roomId}`);
const avatarEvent = room.currentState.getStateEvents(EventType.RoomAvatar, "");
let avatarUrl = avatarEvent?.getContent()["url"] ?? null;
if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96);
const avatarUrl = avatarEvent?.getContent()["url"] ?? null;
const topicEvent = room.currentState.getStateEvents(EventType.RoomTopic, "");
const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()["topic"] : "";
@ -71,8 +69,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
originalDisplayName: name,
displayName: name,
originalAvatarUrl: avatarUrl,
avatarUrl: avatarUrl,
avatarFile: null,
avatarRemovalPending: false,
originalTopic: topic,
topic: topic,
profileFieldsTouched: {},
@ -82,16 +80,23 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
};
}
private uploadAvatar = (): void => {
this.avatarUpload.current?.click();
private onAvatarChanged = (file: File): void => {
this.setState({
avatarFile: file,
avatarRemovalPending: false,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
});
};
private removeAvatar = (): void => {
// clear file upload field so same file can be selected
if (this.avatarUpload.current) this.avatarUpload.current.value = "";
this.setState({
avatarUrl: null,
avatarFile: null,
avatarRemovalPending: true,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
@ -112,8 +117,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
profileFieldsTouched: {},
displayName: this.state.originalDisplayName,
topic: this.state.originalTopic,
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
avatarRemovalPending: false,
});
};
@ -138,11 +143,12 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
if (this.state.avatarFile) {
const { content_uri: uri } = await client.uploadContent(this.state.avatarFile);
await client.sendStateEvent(this.props.roomId, EventType.RoomAvatar, { url: uri }, "");
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
newState.originalAvatarUrl = newState.avatarUrl;
newState.originalAvatarUrl = uri;
newState.avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
} else if (this.state.avatarRemovalPending) {
await client.sendStateEvent(this.props.roomId, EventType.RoomAvatar, {}, "");
newState.avatarRemovalPending = false;
newState.originalAvatarUrl = null;
}
if (this.state.originalTopic !== this.state.topic) {
@ -192,34 +198,6 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
}
};
private onAvatarChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files || !e.target.files.length) {
this.setState({
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: false,
},
});
return;
}
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (ev) => {
this.setState({
avatarUrl: String(ev.target?.result),
avatarFile: file,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
});
};
reader.readAsDataURL(file);
};
public render(): React.ReactNode {
let profileSettingsButtons;
if (this.state.canSetName || this.state.canSetTopic || this.state.canSetAvatar) {
@ -241,14 +219,6 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
return (
<form onSubmit={this.saveProfile} autoComplete="off" noValidate={true} className="mx_ProfileSettings">
<input
type="file"
ref={this.avatarUpload}
className="mx_ProfileSettings_avatarUpload"
onClick={chromeFileInputFix}
onChange={this.onAvatarChanged}
accept="image/*"
/>
<div className="mx_ProfileSettings_profile">
<div className="mx_ProfileSettings_profile_controls">
<Field
@ -275,11 +245,15 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
/>
</div>
<AvatarSetting
avatarUrl={this.state.avatarUrl ?? undefined}
avatarName={this.state.displayName || this.props.roomId}
avatar={
this.state.avatarRemovalPending
? undefined
: this.state.avatarFile ?? this.state.originalAvatarUrl ?? undefined
}
avatarAltText={_t("room_settings|general|avatar_field_label")}
uploadAvatar={this.state.canSetAvatar ? this.uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this.removeAvatar : undefined}
disabled={!this.state.canSetAvatar}
onChange={this.onAvatarChanged}
removeAvatar={this.removeAvatar}
/>
</div>
{profileSettingsButtons}

View file

@ -1162,10 +1162,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
let msgOption: JSX.Element | undefined;
if (this.props.showReadReceipts) {
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
msgOption = <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
} else {
} else if (this.props.showReadReceipts) {
msgOption = (
<ReadReceiptGroup
readReceipts={this.props.readReceipts ?? []}
@ -1176,7 +1175,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
/>
);
}
}
let replyChain: JSX.Element | undefined;
if (

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from "react";
import { RovingAccessibleTooltipButton } from "../../../../accessibility/RovingTabIndex";
import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex";
import Toolbar from "../../../../accessibility/Toolbar";
import { _t } from "../../../../languageHandler";
import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg";
@ -32,22 +32,22 @@ export function EventTileThreadToolbar({
}): JSX.Element {
return (
<Toolbar className="mx_MessageActionBar" aria-label={_t("timeline|mab|label")} aria-live="off">
<RovingAccessibleTooltipButton
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
onClick={viewInRoom}
title={_t("timeline|mab|view_in_room")}
key="view_in_room"
>
<ViewInRoomIcon />
</RovingAccessibleTooltipButton>
<RovingAccessibleTooltipButton
</RovingAccessibleButton>
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
onClick={copyLinkToThread}
title={_t("timeline|mab|copy_link_thread")}
key="copy_link_to_thread"
>
<LinkIcon />
</RovingAccessibleTooltipButton>
</RovingAccessibleButton>
</Toolbar>
);
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import classNames from "classnames";
import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import NotificationBadge from "./NotificationBadge";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import { ButtonEvent } from "../elements/AccessibleButton";
@ -73,15 +73,15 @@ export default function ExtraTile({
);
if (isMinimized) nameContainer = null;
const Button = isMinimized ? RovingAccessibleTooltipButton : RovingAccessibleButton;
return (
<Button
<RovingAccessibleButton
className={classes}
onMouseEnter={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={onClick}
role="treeitem"
title={isMinimized ? name : undefined}
title={name}
disableTooltip={!isMinimized}
>
<div className="mx_RoomTile_avatarContainer">{avatar}</div>
<div className="mx_RoomTile_details">
@ -90,6 +90,6 @@ export default function ExtraTile({
<div className="mx_RoomTile_badgeContainer">{badge}</div>
</div>
</div>
</Button>
</RovingAccessibleButton>
);
}

View file

@ -25,6 +25,7 @@ import {
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import { Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -40,7 +41,6 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
import { RecordingState } from "../../../audio/VoiceRecording";
import Tooltip, { Alignment } from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from "../../../utils/ShieldUtils";
import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer";
@ -110,7 +110,6 @@ interface IState {
}
export class MessageComposer extends React.Component<IProps, IState> {
private tooltipId = `mx_MessageComposer_${Math.random()}`;
private dispatcherRef?: string;
private messageComposerInput = createRef<SendMessageComposerClass>();
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
@ -568,12 +567,9 @@ export class MessageComposer extends React.Component<IProps, IState> {
}
let recordingTooltip: JSX.Element | undefined;
if (this.state.recordingTimeLeftSeconds) {
const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds);
recordingTooltip = (
<Tooltip id={this.tooltipId} label={formatTimeLeft(secondsLeft)} alignment={Alignment.Top} />
);
}
const isTooltipOpen = Boolean(this.state.recordingTimeLeftSeconds);
const secondsLeft = this.state.recordingTimeLeftSeconds ? Math.round(this.state.recordingTimeLeftSeconds) : 0;
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null;
@ -599,13 +595,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
});
return (
<div
className={classes}
ref={this.ref}
aria-describedby={this.state.recordingTimeLeftSeconds ? this.tooltipId : undefined}
role="region"
aria-label={_t("a11y|message_composer")}
>
<Tooltip open={isTooltipOpen} label={formatTimeLeft(secondsLeft)} placement="top">
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
{recordingTooltip}
<div className="mx_MessageComposer_wrapper">
<ReplyPreview
@ -653,7 +644,9 @@ export class MessageComposer extends React.Component<IProps, IState> {
key="controls_send"
onClick={this.sendMessage}
title={
this.state.haveRecording ? _t("composer|send_button_voice_message") : undefined
this.state.haveRecording
? _t("composer|send_button_voice_message")
: undefined
}
/>
)}
@ -661,6 +654,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
</div>
</div>
</div>
</Tooltip>
);
}
}

View file

@ -18,7 +18,7 @@ import React, { createRef } from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import Toolbar from "../../../accessibility/Toolbar";
export enum Formatting {
@ -131,7 +131,7 @@ class FormatButton extends React.PureComponent<IFormatButtonProps> {
// element="button" and type="button" are necessary for the buttons to work on WebKit,
// otherwise the text is deselected before onClick can ever be called
return (
<RovingAccessibleTooltipButton
<RovingAccessibleButton
element="button"
type="button"
onClick={this.props.onClick}

View file

@ -16,18 +16,17 @@ limitations under the License.
import React, { PropsWithChildren } from "react";
import { User } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web";
import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker";
import { IReadReceiptProps } from "./EventTile";
import AccessibleButton from "../elements/AccessibleButton";
import MemberAvatar from "../avatars/MemberAvatar";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { Alignment } from "../elements/Tooltip";
import { formatDate } from "../../../DateUtils";
import { Action } from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
import ContextMenu, { aboveLeftOf, MenuItem, useContextMenu } from "../../structures/ContextMenu";
import { useTooltip } from "../../../utils/useTooltip";
import { _t } from "../../../languageHandler";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import { formatList } from "../../../utils/FormattingUtils";
@ -87,18 +86,6 @@ export function ReadReceiptGroup({
const tooltipMembers: string[] = readReceipts.map((it) => it.roomMember?.name ?? it.userId);
const tooltipText = readReceiptTooltip(tooltipMembers, maxAvatars);
const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
label: (
<>
<div className="mx_Tooltip_title">
{_t("timeline|read_receipt_title", { count: readReceipts.length })}
</div>
<div className="mx_Tooltip_sub">{tooltipText}</div>
</>
),
alignment: Alignment.TopRight,
});
// return early if there are no read receipts
if (readReceipts.length === 0) {
// We currently must include `mx_ReadReceiptGroup_container` in
@ -185,6 +172,11 @@ export function ReadReceiptGroup({
return (
<div className="mx_EventTile_msgOption">
<Tooltip
label={_t("timeline|read_receipt_title", { count: readReceipts.length })}
caption={tooltipText}
placement="top-end"
>
<div className="mx_ReadReceiptGroup" role="group" aria-label={_t("timeline|read_receipts_label")}>
<AccessibleButton
className="mx_ReadReceiptGroup_button"
@ -192,10 +184,6 @@ export function ReadReceiptGroup({
aria-label={tooltipText}
aria-haspopup="true"
onClick={openMenu}
onMouseOver={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{remText}
<span
@ -210,9 +198,9 @@ export function ReadReceiptGroup({
{avatars}
</span>
</AccessibleButton>
{tooltip}
{contextMenu}
</div>
</Tooltip>
</div>
);
}
@ -222,25 +210,17 @@ interface ReadReceiptPersonProps extends IReadReceiptProps {
onAfterClick?: () => void;
}
function ReadReceiptPerson({
// Export for testing
export function ReadReceiptPerson({
userId,
roomMember,
ts,
isTwelveHour,
onAfterClick,
}: ReadReceiptPersonProps): JSX.Element {
const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
alignment: Alignment.Top,
tooltipClassName: "mx_ReadReceiptGroup_person--tooltip",
label: (
<>
<div className="mx_Tooltip_title">{roomMember?.rawDisplayName ?? userId}</div>
<div className="mx_Tooltip_sub">{userId}</div>
</>
),
});
return (
<Tooltip label={roomMember?.rawDisplayName ?? userId} caption={userId} placement="top">
<div>
<MenuItem
className="mx_ReadReceiptGroup_person"
onClick={() => {
@ -255,11 +235,6 @@ function ReadReceiptPerson({
});
onAfterClick?.();
}}
onMouseOver={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
onWheel={hideTooltip}
>
<MemberAvatar
member={roomMember}
@ -274,8 +249,9 @@ function ReadReceiptPerson({
<p>{roomMember?.name ?? userId}</p>
<p className="mx_ReadReceiptGroup_secondary">{formatDate(new Date(ts), isTwelveHour)}</p>
</div>
{tooltip}
</MenuItem>
</div>
</Tooltip>
);
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2024 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,51 +14,102 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useRef, useState } from "react";
import classNames from "classnames";
import React, { createRef, useCallback, useEffect, useRef, useState } from "react";
import { _t } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleButton from "../elements/AccessibleButton";
import { mediaFromMxc } from "../../../customisations/Media";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
interface IProps {
avatarUrl?: string;
avatarName: string; // name of user/room the avatar belongs to
uploadAvatar?: (e: ButtonEvent) => void;
removeAvatar?: (e: ButtonEvent) => void;
/**
* The current value of the avatar URL, as an mxc URL or a File.
* Generally, an mxc URL would be specified until the user selects a file, then
* the file supplied by the onChange callback would be supplied here until it's
* saved.
*/
avatar?: string | File;
/**
* If true, the user cannot change the avatar
*/
disabled?: boolean;
/**
* Called when the user has selected a new avatar
* The callback is passed a File object for the new avatar data
*/
onChange?: (f: File) => void;
/**
* Called when the user wishes to remove the avatar
*/
removeAvatar?: () => void;
/**
* The alt text for the avatar
*/
avatarAltText: string;
}
const AvatarSetting: React.FC<IProps> = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, removeAvatar }) => {
const [isHovering, setIsHovering] = useState(false);
const hoveringProps = {
onMouseEnter: () => setIsHovering(true),
onMouseLeave: () => setIsHovering(false),
/**
* Component for setting or removing an avatar on something (eg. a user or a room)
*/
const AvatarSetting: React.FC<IProps> = ({ avatar, avatarAltText, onChange, removeAvatar, disabled }) => {
const fileInputRef = createRef<HTMLInputElement>();
// Real URL that we can supply to the img element, either a data URL or whatever mediaFromMxc gives
// This represents whatever avatar the user has chosen at the time
const [avatarURL, setAvatarURL] = useState<string | undefined>(undefined);
useEffect(() => {
if (avatar instanceof File) {
const reader = new FileReader();
reader.onload = () => {
setAvatarURL(reader.result as string);
};
reader.readAsDataURL(avatar);
} else if (avatar) {
setAvatarURL(mediaFromMxc(avatar).getSquareThumbnailHttp(96) ?? undefined);
} else {
setAvatarURL(undefined);
}
}, [avatar]);
// TODO: Use useId() as soon as we're using React 18.
// Prevents ID collisions when this component is used more than once on the same page.
const a11yId = useRef(`hover-text-${Math.random()}`);
const onFileChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) onChange?.(e.target.files[0]);
},
[onChange],
);
const uploadAvatar = useCallback((): void => {
fileInputRef.current?.click();
}, [fileInputRef]);
let avatarElement = (
<AccessibleButton
element="div"
onClick={uploadAvatar ?? null}
className="mx_AvatarSetting_avatarPlaceholder"
aria-labelledby={uploadAvatar ? a11yId.current : undefined}
onClick={uploadAvatar}
className="mx_AvatarSetting_avatarPlaceholder mx_AvatarSetting_avatarDisplay"
aria-labelledby={disabled ? undefined : a11yId.current}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
{...hoveringProps}
/>
);
if (avatarUrl) {
if (avatarURL) {
avatarElement = (
<AccessibleButton
element="img"
src={avatarUrl}
className="mx_AvatarSetting_avatarDisplay"
src={avatarURL}
alt={avatarAltText}
onClick={uploadAvatar ?? null}
onClick={uploadAvatar}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
{...hoveringProps}
/>
);
}
@ -67,17 +118,27 @@ const AvatarSetting: React.FC<IProps> = ({ avatarUrl, avatarAltText, avatarName,
if (uploadAvatar) {
// insert an empty div to be the host for a css mask containing the upload.svg
uploadAvatarBtn = (
<>
<AccessibleButton
onClick={uploadAvatar}
className="mx_AvatarSetting_uploadButton"
aria-labelledby={a11yId.current}
{...hoveringProps}
/>
<input
type="file"
style={{ display: "none" }}
ref={fileInputRef}
onClick={chromeFileInputFix}
onChange={onFileChanged}
accept="image/*"
alt={_t("action|upload")}
/>
</>
);
}
let removeAvatarBtn: JSX.Element | undefined;
if (avatarUrl && removeAvatar) {
if (avatarURL && removeAvatar && !disabled) {
removeAvatarBtn = (
<AccessibleButton onClick={removeAvatar} kind="link_sm">
{_t("action|remove")}
@ -85,16 +146,12 @@ const AvatarSetting: React.FC<IProps> = ({ avatarUrl, avatarAltText, avatarName,
);
}
const avatarClasses = classNames({
mx_AvatarSetting_avatar: true,
mx_AvatarSetting_avatar_hovering: isHovering && uploadAvatar,
});
return (
<div className={avatarClasses} role="group" aria-label={avatarAltText}>
<div className="mx_AvatarSetting_avatar" role="group" aria-label={avatarAltText}>
{avatarElement}
<div className="mx_AvatarSetting_hover" aria-hidden="true">
<div className="mx_AvatarSetting_hoverBg" />
{uploadAvatar && <span id={a11yId.current}>{_t("action|upload")}</span>}
{!disabled && <span id={a11yId.current}>{_t("action|upload")}</span>}
</div>
{uploadAvatarBtn}
{removeAvatarBtn}

View file

@ -99,9 +99,6 @@ export default class ChangePassword extends React.Component<IProps, IState> {
type: "m.id.user",
user: cli.credentials.userId,
},
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
user: cli.credentials.userId ?? undefined,
password: oldPassword,
};

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2024 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.
@ -23,11 +23,9 @@ import Field from "../elements/Field";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import { mediaFromMxc } from "../../../customisations/Media";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AvatarSetting from "./AvatarSetting";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import PosthogTrackers from "../../../PosthogTrackers";
import { SettingsSubsectionHeading } from "./shared/SettingsSubsectionHeading";
@ -35,8 +33,9 @@ interface IState {
originalDisplayName: string;
displayName: string;
originalAvatarUrl: string | null;
avatarUrl?: string | ArrayBuffer;
avatarFile?: File | null;
// If true, the user has indicated that they wish to remove the avatar and this should happen on save.
avatarRemovalPending: boolean;
enableProfileSave?: boolean;
}
@ -48,20 +47,24 @@ export default class ProfileSettings extends React.Component<{}, IState> {
super(props);
this.userId = MatrixClientPeg.safeGet().getSafeUserId();
let avatarUrl = OwnProfileStore.instance.avatarMxc;
if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96);
const avatarUrl = OwnProfileStore.instance.avatarMxc;
this.state = {
originalDisplayName: OwnProfileStore.instance.displayName ?? "",
displayName: OwnProfileStore.instance.displayName ?? "",
originalAvatarUrl: avatarUrl,
avatarUrl: avatarUrl ?? undefined,
avatarFile: null,
avatarRemovalPending: false,
enableProfileSave: false,
};
}
private uploadAvatar = (): void => {
this.avatarUpload.current?.click();
private onChange = (file: File): void => {
PosthogTrackers.trackInteraction("WebProfileSettingsAvatarUploadButton");
this.setState({
avatarFile: file,
avatarRemovalPending: false,
enableProfileSave: true,
});
};
private removeAvatar = (): void => {
@ -70,8 +73,8 @@ export default class ProfileSettings extends React.Component<{}, IState> {
this.avatarUpload.current.value = "";
}
this.setState({
avatarUrl: undefined,
avatarFile: null,
avatarRemovalPending: true,
enableProfileSave: true,
});
};
@ -84,8 +87,8 @@ export default class ProfileSettings extends React.Component<{}, IState> {
this.setState({
enableProfileSave: false,
displayName: this.state.originalDisplayName,
avatarUrl: this.state.originalAvatarUrl ?? undefined,
avatarFile: null,
avatarRemovalPending: false,
});
};
@ -114,11 +117,12 @@ export default class ProfileSettings extends React.Component<{}, IState> {
);
const { content_uri: uri } = await client.uploadContent(this.state.avatarFile);
await client.setAvatarUrl(uri);
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96) ?? undefined;
newState.originalAvatarUrl = newState.avatarUrl;
newState.originalAvatarUrl = uri;
newState.avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
} else if (this.state.avatarRemovalPending) {
await client.setAvatarUrl(""); // use empty string as Synapse 500s on undefined
newState.originalAvatarUrl = null;
newState.avatarRemovalPending = false;
}
} catch (err) {
logger.log("Failed to save profile", err);
@ -138,50 +142,13 @@ export default class ProfileSettings extends React.Component<{}, IState> {
});
};
private onAvatarChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files || !e.target.files.length) {
this.setState({
avatarUrl: this.state.originalAvatarUrl ?? undefined,
avatarFile: null,
enableProfileSave: false,
});
return;
}
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (ev) => {
this.setState({
avatarUrl: ev.target?.result ?? undefined,
avatarFile: file,
enableProfileSave: true,
});
};
reader.readAsDataURL(file);
};
public render(): React.ReactNode {
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.userId, {
withDisplayName: true,
});
// False negative result from no-base-to-string rule, doesn't seem to account for Symbol.toStringTag
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const avatarUrl = this.state.avatarUrl?.toString();
return (
<form onSubmit={this.saveProfile} autoComplete="off" noValidate={true} className="mx_ProfileSettings">
<input
type="file"
ref={this.avatarUpload}
className="mx_ProfileSettings_avatarUpload"
onClick={(ev) => {
chromeFileInputFix(ev);
PosthogTrackers.trackInteraction("WebProfileSettingsAvatarUploadButton", ev);
}}
onChange={this.onAvatarChanged}
accept="image/*"
/>
<div className="mx_ProfileSettings_profile">
<div className="mx_ProfileSettings_profile_controls">
<SettingsSubsectionHeading heading={_t("common|profile")} />
@ -199,10 +166,13 @@ export default class ProfileSettings extends React.Component<{}, IState> {
</p>
</div>
<AvatarSetting
avatarUrl={avatarUrl}
avatarName={this.state.displayName || this.userId}
avatar={
this.state.avatarRemovalPending
? undefined
: this.state.avatarFile ?? this.state.originalAvatarUrl ?? undefined
}
avatarAltText={_t("common|user_avatar")}
uploadAvatar={this.uploadAvatar}
onChange={this.onChange}
removeAvatar={this.removeAvatar}
/>
</div>

View file

@ -19,10 +19,10 @@ import React, { ComponentProps } from "react";
import { Icon as CaretIcon } from "../../../../../res/img/feather-customised/dropdown-arrow.svg";
import { _t } from "../../../../languageHandler";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
import AccessibleButton from "../../elements/AccessibleButton";
type Props<T extends keyof JSX.IntrinsicElements> = Omit<
ComponentProps<typeof AccessibleTooltipButton<T>>,
ComponentProps<typeof AccessibleButton<T>>,
"aria-label" | "title" | "kind" | "className" | "onClick" | "element"
> & {
isExpanded: boolean;
@ -36,7 +36,7 @@ export const DeviceExpandDetailsButton = <T extends keyof JSX.IntrinsicElements>
}: Props<T>): JSX.Element => {
const label = isExpanded ? _t("settings|sessions|hide_details") : _t("settings|sessions|show_details");
return (
<AccessibleTooltipButton
<AccessibleButton
{...rest}
aria-label={label}
title={label}
@ -47,6 +47,6 @@ export const DeviceExpandDetailsButton = <T extends keyof JSX.IntrinsicElements>
onClick={onClick}
>
<CaretIcon className="mx_DeviceExpandDetailsButton_icon" />
</AccessibleTooltipButton>
</AccessibleButton>
);
};

View file

@ -62,17 +62,16 @@ import QuickSettingsButton from "./QuickSettingsButton";
import { useSettingValue } from "../../../hooks/useSettings";
import UserMenu from "../../structures/UserMenu";
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
import { IS_MAC, Key } from "../../../Keyboard";
import { useDispatcher } from "../../../hooks/useDispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../dispatcher/payloads";
import { Action } from "../../../dispatcher/actions";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { ThreadsActivityCentre } from "./threads-activity-centre/";
import AccessibleButton from "../elements/AccessibleButton";
import { KeyboardShortcut } from "../settings/KeyboardShortcut";
const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
@ -393,14 +392,11 @@ const SpacePanel: React.FC = () => {
className={classNames("mx_SpacePanel_toggleCollapse", { expanded: !isPanelCollapsed })}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
// TODO should use a kbd element for accessibility https://developer.mozilla.org/en-US/docs/Web/HTML/Element/kbd
caption={
IS_MAC
? "⌘ + ⇧ + D"
: _t(ALTERNATE_KEY_NAME[Key.CONTROL]) +
" + " +
_t(ALTERNATE_KEY_NAME[Key.SHIFT]) +
" + D"
<KeyboardShortcut
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
/>
}
/>
</UserMenu>

View file

@ -48,7 +48,7 @@ export function formatRange(range: Range, action: Formatting): void {
toggleInlineFormat(range, "**");
break;
case Formatting.Italics:
toggleInlineFormat(range, "_");
toggleInlineFormat(range, "*");
break;
case Formatting.Strikethrough:
toggleInlineFormat(range, "<del>", "</del>");

View file

@ -16,17 +16,34 @@
* /
*/
import { useTypedEventEmitterState } from "./useEventEmitter";
import { useState } from "react";
import { useTypedEventEmitter, useTypedEventEmitterState } from "./useEventEmitter";
import { Feature, ReleaseAnnouncementStore } from "../stores/ReleaseAnnouncementStore";
import Modal, { ModalManagerEvent } from "../Modal";
/**
* Hook to return true if a modal is opened
*/
function useModalOpened(): boolean {
const [opened, setOpened] = useState(false);
useTypedEventEmitter(Modal, ModalManagerEvent.Opened, () => setOpened(true));
// Modal can be stacked, we need to check if all dialogs are closed
useTypedEventEmitter(Modal, ModalManagerEvent.Closed, () => !Modal.hasDialogs() && setOpened(false));
return opened;
}
/**
* Return true if the release announcement of the given feature is enabled
* @param feature
*/
export function useIsReleaseAnnouncementOpen(feature: Feature): boolean {
return useTypedEventEmitterState(
const modalOpened = useModalOpened();
const releaseAnnouncementOpened = useTypedEventEmitterState(
ReleaseAnnouncementStore.instance,
"releaseAnnouncementChanged",
() => ReleaseAnnouncementStore.instance.getReleaseAnnouncement() === feature,
);
return !modalOpened && releaseAnnouncementOpened;
}

View file

@ -90,7 +90,7 @@ export const usePublicRoomDirectory = (): {
async ({ limit = 20, query, roomTypes }: IPublicRoomsOpts): Promise<boolean> => {
const opts: IRoomDirectoryOptions = { limit };
if (config?.roomServer != MatrixClientPeg.getHomeserverName()) {
if (config?.roomServer != MatrixClientPeg.safeGet().getDomain()) {
opts.server = config?.roomServer;
}
@ -139,7 +139,7 @@ export const usePublicRoomDirectory = (): {
return;
}
const myHomeserver = MatrixClientPeg.getHomeserverName();
const myHomeserver = MatrixClientPeg.safeGet().getDomain()!;
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
const lsInstanceId: string | undefined = localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined;

View file

@ -3424,8 +3424,7 @@
"reactions": {
"add_reaction_prompt": "Přidat reakci",
"custom_reaction_fallback_label": "Vlastní reakce",
"label": "%(reactors)s reagoval(a) na %(content)s",
"tooltip": "<reactors/><reactedWith> reagoval(a) %(shortName)s</reactedWith>"
"label": "%(reactors)s reagoval(a) na %(content)s"
},
"read_receipt_title": {
"one": "Viděl %(count)s člověk",

View file

@ -3393,8 +3393,7 @@
"reactions": {
"add_reaction_prompt": "Reaktion hinzufügen",
"custom_reaction_fallback_label": "Benutzerdefinierte Reaktion",
"label": "%(reactors)s hat mit %(content)s reagiert",
"tooltip": "<reactors/><reactedWith>hat mit %(shortName)s reagiert</reactedWith>"
"label": "%(reactors)s hat mit %(content)s reagiert"
},
"read_receipt_title": {
"one": "Von %(count)s Person gesehen",

View file

@ -2720,8 +2720,7 @@
"pending_moderation_reason": "Μήνυμα σε εκκρεμότητα συντονισμού: %(reason)s",
"reactions": {
"add_reaction_prompt": "Προσθέστε αντίδραση",
"label": "%(reactors)s αντέδρασαν με %(content)s",
"tooltip": "<reactors/><reactedWith>αντέδρασε με %(shortName)s</reactedWith>"
"label": "%(reactors)s αντέδρασαν με %(content)s"
},
"read_receipt_title": {
"one": "Αναγνώστηκε από %(count)s άτομο",

View file

@ -3483,7 +3483,7 @@
"add_reaction_prompt": "Add reaction",
"custom_reaction_fallback_label": "Custom reaction",
"label": "%(reactors)s reacted with %(content)s",
"tooltip": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>"
"tooltip_caption": "reacted with %(shortName)s"
},
"read_receipt_title": {
"one": "Seen by %(count)s person",

View file

@ -2451,8 +2451,7 @@
},
"reactions": {
"add_reaction_prompt": "Aldoni reagon",
"label": "%(reactors)s reagis per %(content)s",
"tooltip": "<reactors/><reactedWith>reagis per %(shortName)s</reactedWith>"
"label": "%(reactors)s reagis per %(content)s"
},
"redacted": {
"tooltip": "Mesaĝo forigita je %(date)s"

View file

@ -3125,8 +3125,7 @@
"pending_moderation_reason": "Mensaje esperando revisión: %(reason)s",
"reactions": {
"add_reaction_prompt": "Reaccionar",
"label": "%(reactors)s han reaccionado con %(content)s",
"tooltip": "<reactors/><reactedWith> reaccionó con %(shortName)s</reactedWith>"
"label": "%(reactors)s han reaccionado con %(content)s"
},
"read_receipt_title": {
"one": "%(count)s persona lo ha visto",

View file

@ -3363,8 +3363,7 @@
"pending_moderation_reason": "Sõnum on modereerimise ootel: %(reason)s",
"reactions": {
"add_reaction_prompt": "Lisa reaktsioon",
"label": "%(reactors)s kasutajat reageeris järgnevalt: %(content)s",
"tooltip": "<reactors/><reactedWith>reageeris(id) %(shortName)s</reactedWith>"
"label": "%(reactors)s kasutajat reageeris järgnevalt: %(content)s"
},
"read_receipt_title": {
"one": "Seda nägi %(count)s lugeja",

View file

@ -2144,8 +2144,7 @@
"updated_rule_users": "%(senderName)s قاعده تحریم کاربران را که با %(glob)s تطابق داشت، به دلیل (دلایل) %(reason)s به‌روزرسانی کرد"
},
"reactions": {
"add_reaction_prompt": "افزودن واکنش",
"tooltip": "<reactors/><reactedWith> واکنش نشان داد با %(shortName)s</reactedWith>"
"add_reaction_prompt": "افزودن واکنش"
},
"redacted": {
"tooltip": "پیام در %(date)s حذف شد"

View file

@ -2995,8 +2995,7 @@
"pending_moderation": "Viesti odottaa moderointia",
"pending_moderation_reason": "Viesti odottaa moderointia: %(reason)s",
"reactions": {
"add_reaction_prompt": "Lisää reaktio",
"tooltip": "<reactors/><reactedWith>reagoi(vat) emojilla %(shortName)s</reactedWith>"
"add_reaction_prompt": "Lisää reaktio"
},
"read_receipt_title": {
"one": "Nähnyt yksi ihminen",

View file

@ -3404,8 +3404,7 @@
"reactions": {
"add_reaction_prompt": "Ajouter une réaction",
"custom_reaction_fallback_label": "Réaction personnalisée",
"label": "%(reactors)s ont réagi avec %(content)s",
"tooltip": "<reactors/><reactedWith>ont réagi avec %(shortName)s</reactedWith>"
"label": "%(reactors)s ont réagi avec %(content)s"
},
"read_receipt_title": {
"one": "Vu par %(count)s personne",

View file

@ -2886,8 +2886,7 @@
"pending_moderation_reason": "Mensaxe pendente de moderar: %(reason)s",
"reactions": {
"add_reaction_prompt": "Engadir reacción",
"label": "%(reactors)s reaccionou con %(content)s",
"tooltip": "<reactors/><reactedWith>reaccionaron con %(shortName)s</reactedWith>"
"label": "%(reactors)s reaccionou con %(content)s"
},
"read_receipt_title": {
"one": "Visto por %(count)s persoa",

View file

@ -2308,9 +2308,6 @@
"updated_rule_servers": "%(senderName)s עדכן את הכללים המאפשרים חסימת שרתים התואמים ל%(glob)s עבור %(reason)s",
"updated_rule_users": "%(senderName)s עדכן את הכלל שמאפשר חסימת משתמשים התואמים ל%(glob)s עבור %(reason)s"
},
"reactions": {
"tooltip": "<reactors/><reactedWith> הגיבו עם %(shortName)s</reactedWith>"
},
"redacted": {
"tooltip": "הודעה נמחקה בתאריך %(date)s"
},

View file

@ -3329,8 +3329,7 @@
"reactions": {
"add_reaction_prompt": "Reakció hozzáadása",
"custom_reaction_fallback_label": "Egyéni reakció",
"label": "%(reactors)s reagált: %(content)s",
"tooltip": "<reactors/><reactedWith>ezzel reagált: %(shortName)s</reactedWith>"
"label": "%(reactors)s reagált: %(content)s"
},
"read_receipt_title": {
"one": "%(count)s ember látta",

View file

@ -3363,8 +3363,7 @@
"reactions": {
"add_reaction_prompt": "Tambahkan reaksi",
"custom_reaction_fallback_label": "Reaksi khusus",
"label": "%(reactors)s berekasi dengan %(content)s",
"tooltip": "<reactors/><reactedWith>bereaksi dengan %(shortName)s</reactedWith>"
"label": "%(reactors)s berekasi dengan %(content)s"
},
"read_receipt_title": {
"one": "Dilihat oleh %(count)s orang",

View file

@ -2800,8 +2800,7 @@
"pending_moderation_reason": "Efni sem bíður yfirferðar: %(reason)s",
"reactions": {
"add_reaction_prompt": "Bæta við viðbrögðum",
"label": "%(reactors)s brást við með %(content)s",
"tooltip": "<reactors/><reactedWith>brást við %(shortName)s</reactedWith>"
"label": "%(reactors)s brást við með %(content)s"
},
"read_receipt_title": {
"one": "Séð af %(count)s aðila",

View file

@ -3417,8 +3417,7 @@
"reactions": {
"add_reaction_prompt": "Aggiungi reazione",
"custom_reaction_fallback_label": "Reazione personalizzata",
"label": "%(reactors)s ha reagito con %(content)s",
"tooltip": "<reactors/><reactedWith>ha reagito con %(shortName)s</reactedWith>"
"label": "%(reactors)s ha reagito con %(content)s"
},
"read_receipt_title": {
"one": "Visto da %(count)s persona",

View file

@ -3135,8 +3135,7 @@
"pending_moderation_reason": "メッセージはモデレートの保留中です:%(reason)s",
"reactions": {
"add_reaction_prompt": "リアクションを追加",
"label": "%(reactors)sは%(content)sでリアクションしました",
"tooltip": "<reactors/><reactedWith>%(shortName)sでリアクションしました</reactedWith>"
"label": "%(reactors)sは%(content)sでリアクションしました"
},
"read_receipt_title": {
"one": "%(count)s人が閲覧済",

View file

@ -2736,8 +2736,7 @@
"pending_moderation_reason": "ຂໍ້ຄວາມທີ່ລໍຖ້າການກວດກາ: %(reason)s",
"reactions": {
"add_reaction_prompt": "ເພີ່ມການຕອບໂຕ້",
"label": "%(reactors)sປະຕິກິລິຍາກັບ %(content)s",
"tooltip": "<reactors/><reactedWith>ປະຕິກິລິຍາດ້ວຍ %(shortName)s</reactedWith>"
"label": "%(reactors)sປະຕິກິລິຍາກັບ %(content)s"
},
"read_receipt_title": {
"one": "ເຫັນໂດຍ %(count)s ຄົນ",

View file

@ -2201,8 +2201,7 @@
"pending_moderation": "Žinutė laukia moderavimo",
"pending_moderation_reason": "Žinutė laukia moderavimo: %(reason)s",
"reactions": {
"add_reaction_prompt": "Pridėti reakciją",
"tooltip": "<reactors/><reactedWith>reagavo su %(shortName)s</reactedWith>"
"add_reaction_prompt": "Pridėti reakciją"
},
"read_receipt_title": {
"one": "Matė %(count)s žmogus",

View file

@ -2908,8 +2908,7 @@
"pending_moderation_reason": "Bericht in afwachting van moderatie: %(reason)s",
"reactions": {
"add_reaction_prompt": "Reactie toevoegen",
"label": "%(reactors)s reageerde met %(content)s",
"tooltip": "<reactors/><reactedWith>heeft gereageerd met %(shortName)s</reactedWith>"
"label": "%(reactors)s reageerde met %(content)s"
},
"read_receipt_title": {
"one": "Gezien door %(count)s persoon",

View file

@ -3445,8 +3445,7 @@
"reactions": {
"add_reaction_prompt": "Dodaj reakcje",
"custom_reaction_fallback_label": "Reakcja niestandardowa",
"label": "%(reactors)s zareagował z %(content)s",
"tooltip": "<reactors/><reactedWith> zareagował z %(shortName)s</reactedWith>"
"label": "%(reactors)s zareagował z %(content)s"
},
"read_receipt_title": {
"one": "Odczytane przez %(count)s osobę",

View file

@ -2349,8 +2349,7 @@
},
"reactions": {
"add_reaction_prompt": "Adicionar reação",
"label": "%(reactors)s reagiram com %(content)s",
"tooltip": "<reactors/><reactedWith>reagiu com %(shortName)s</reactedWith>"
"label": "%(reactors)s reagiram com %(content)s"
},
"read_receipt_title": {
"one": "Visto por %(count)s pessoa",

View file

@ -3394,8 +3394,7 @@
"reactions": {
"add_reaction_prompt": "Отреагировать",
"custom_reaction_fallback_label": "Пользовательская реакция",
"label": "%(reactors)s отреагировали %(content)s",
"tooltip": "<reactors/><reactedWith>отреагировал с %(shortName)s</reactedWith>"
"label": "%(reactors)s отреагировали %(content)s"
},
"read_receipt_title": {
"one": "Просмотрел %(count)s человек",

View file

@ -3397,8 +3397,7 @@
"reactions": {
"add_reaction_prompt": "Pridať reakciu",
"custom_reaction_fallback_label": "Vlastná reakcia",
"label": "%(reactors)s reagovali %(content)s",
"tooltip": "<reactors/><reactedWith>reagoval %(shortName)s</reactedWith>"
"label": "%(reactors)s reagovali %(content)s"
},
"read_receipt_title": {
"one": "Videl %(count)s človek",

View file

@ -3199,8 +3199,7 @@
"pending_moderation_reason": "Mesazh në pritje të moderimit: %(reason)s",
"reactions": {
"add_reaction_prompt": "Shtoni reagim",
"label": "%(reactors)s reagoi me %(content)s",
"tooltip": "<reactors/><reactedWith>reagoi me %(shortName)s</reactedWith>"
"label": "%(reactors)s reagoi me %(content)s"
},
"read_receipt_title": {
"one": "Parë nga %(count)s person",

View file

@ -3416,8 +3416,7 @@
"reactions": {
"add_reaction_prompt": "Lägg till reaktion",
"custom_reaction_fallback_label": "Anpassad reaktion",
"label": "%(reactors)s reagerade med %(content)s",
"tooltip": "<reactors/><reactedWith>reagerade med %(shortName)s</reactedWith>"
"label": "%(reactors)s reagerade med %(content)s"
},
"read_receipt_title": {
"one": "Sedd av %(count)s person",

View file

@ -3324,8 +3324,7 @@
"pending_moderation_reason": "Повідомлення очікує модерування: %(reason)s",
"reactions": {
"add_reaction_prompt": "Додати реакцію",
"label": "%(reactors)s додає реакцію %(content)s",
"tooltip": "<reactors/><reactedWith>додає реакцію %(shortName)s</reactedWith>"
"label": "%(reactors)s додає реакцію %(content)s"
},
"read_receipt_title": {
"one": "Переглянули %(count)s осіб",

View file

@ -3060,8 +3060,7 @@
"pending_moderation_reason": "Tin nhắn chờ duyệt: %(reason)s",
"reactions": {
"add_reaction_prompt": "Thêm phản ứng",
"label": "%(reactors)s đã phản hồi với %(content)s",
"tooltip": "<reactors/><reactedWith>đã phản hồi với %(shortName)s</reactedWith>"
"label": "%(reactors)s đã phản hồi với %(content)s"
},
"read_receipt_title": {
"one": "Gửi bởi %(count)s người",

View file

@ -3051,8 +3051,7 @@
"pending_moderation_reason": "消息待审核:%(reason)s",
"reactions": {
"add_reaction_prompt": "添加反应",
"label": "%(reactors)s做出了%(content)s的反应",
"tooltip": "<reactors/><reactedWith>回应了 %(shortName)s</reactedWith>"
"label": "%(reactors)s做出了%(content)s的反应"
},
"read_receipt_title": {
"one": "已被%(count)s人查看",

View file

@ -3315,8 +3315,7 @@
"pending_moderation_reason": "待審核的訊息:%(reason)s",
"reactions": {
"add_reaction_prompt": "新增反應",
"label": "%(reactors)s 使用了 %(content)s 反應",
"tooltip": "<reactors/><reactedWith> 反應時使用 %(shortName)s</reactedWith>"
"label": "%(reactors)s 使用了 %(content)s 反應"
},
"read_receipt_title": {
"one": "已被 %(count)s 個人看過",

View file

@ -17,7 +17,7 @@ limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { Method, MatrixClient, CryptoApi } from "matrix-js-sdk/src/matrix";
import { Method, MatrixClient, Crypto } from "matrix-js-sdk/src/matrix";
import type * as Pako from "pako";
import { MatrixClientPeg } from "../MatrixClientPeg";
@ -177,7 +177,7 @@ async function collectSynapseSpecific(client: MatrixClient, body: FormData): Pro
/**
* Collects crypto related information.
*/
async function collectCryptoInfo(cryptoApi: CryptoApi, body: FormData): Promise<void> {
async function collectCryptoInfo(cryptoApi: Crypto.CryptoApi, body: FormData): Promise<void> {
body.append("crypto_version", cryptoApi.getVersion());
const ownDeviceKeys = await cryptoApi.getOwnDeviceKeys();
@ -206,7 +206,7 @@ async function collectCryptoInfo(cryptoApi: CryptoApi, body: FormData): Promise<
/**
* Collects information about secret storage and backup.
*/
async function collectRecoveryInfo(client: MatrixClient, cryptoApi: CryptoApi, body: FormData): Promise<void> {
async function collectRecoveryInfo(client: MatrixClient, cryptoApi: Crypto.CryptoApi, body: FormData): Promise<void> {
const secretStorage = client.secretStorage;
body.append("secret_storage_ready", String(await cryptoApi.isSecretStorageReady()));
body.append("secret_storage_key_in_account", String(await secretStorage.hasKey()));

View file

@ -18,6 +18,7 @@
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { cloneDeep } from "lodash";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
@ -90,7 +91,8 @@ export class ReleaseAnnouncementStore extends TypedEventEmitter<ReleaseAnnouncem
* @private
*/
private getViewedReleaseAnnouncements(): StoredSettings {
return SettingsStore.getValue<StoredSettings>("releaseAnnouncementData");
// Clone the settings to avoid to mutate the internal stored value in the SettingsStore
return cloneDeep(SettingsStore.getValue<StoredSettings>("releaseAnnouncementData"));
}
/**

View file

@ -68,7 +68,7 @@ const mapAutoDiscoveryErrorTranslation = (err: AutoDiscoveryError): TranslationK
return _td("auth|autodiscovery_no_well_known");
case AutoDiscoveryError.InvalidJson:
return _td("auth|autodiscovery_invalid_json");
case AutoDiscoveryError.HomeserverTooOld:
case AutoDiscoveryError.UnsupportedHomeserverSpecVersion:
return _td("auth|autodiscovery_hs_incompatible");
}
};

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