Merge remote-tracking branch 'origin/develop' into staging
This commit is contained in:
commit
ca16462265
155 changed files with 2930 additions and 1851 deletions
15
package.json
15
package.json
|
@ -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",
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -472,3 +472,10 @@ limitations under the License.
|
|||
.mx_SpacePanel_sharePublicSpace {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mx_SpacePanel_Tooltip_KeyboardShortcut {
|
||||
kbd {
|
||||
font-family: inherit;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -204,7 +204,7 @@ limitations under the License.
|
|||
}
|
||||
|
||||
.mx_LegacyCallEvent_info {
|
||||
align-items: unset;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -253,6 +253,7 @@ const SpaceLanding: React.FC<{ space: Room }> = ({ space }) => {
|
|||
showSpaceSettings(space);
|
||||
}}
|
||||
title={_t("common|settings")}
|
||||
placement="bottom"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>");
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 άτομο",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 حذف شد"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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人が閲覧済",
|
||||
|
|
|
@ -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 ຄົນ",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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ę",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 человек",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 осіб",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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人查看",
|
||||
|
|
|
@ -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 個人看過",
|
||||
|
|
|
@ -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()));
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue