element-portable/src/components/views/rooms/DecryptionFailureBar.tsx
Suguru Hirahara 232daaff68
Fix decryption failure bar covering the timeline (#10360)
* Use grid layout instead

- BEM naming style
- Increase block gap from 4px to 8px
- Use flexbox inside 'header' grid-area to let the buttons wrapped
- Use variables
- Remove 4px gap when one of the buttons is not rendered
- Change 'body' to 'message'
- Set 'align-self: start' to the icon and spinner

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Unset height of spinner

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Break lines at newline characters with white-space: pre-line

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Edit tests to check decryption failure bars on narrow timeline

- checkTimelineNarrow() looks for buttons by default
- Test indicator as well

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Remove a line

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Edit the test to have it check mx_EventTile_last only inside mx_RoomView_body

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Fix double underscores

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Fix double underscores - pcss

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Iterate - buttons at the bottom

- Set common spacing to buttons with variables
- Remove line breaks, yarn run i18n
- Set data-testid for headlines and buttons in case the tested strings would be displayed elsewhere simultaneously

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Check waiting headline as well

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Increase spacing between the message and the buttons

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* lint

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Increase block gap between wrapped buttons for clickability

Apply 8px between wrapped buttons

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Revert bottom margin of buttons which are not expected to be wrapped

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Check visibility instead of existence

This commit removes data-testid from headlines and data-testid-button and checks whether the elements are really visible, not overflowing the viewport.

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Remove redundant gap between 'mx_DecryptionFailureBar_start' and the bottom edge

This commit adds '.mx_DecryptionFailureBar--withEnd' class name to have it applied to the bar only if it has button(s). This way the bar is rendered with a flexbox and the row-gap declaration is respected only if there is a 'mx_DecryptionFailureBar--withEnd' element. The element  currently includes the button(s) only.

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* lint - prettier

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

* Have Percy take a snapshot of the bar loading spinner before checkTimelineNarrow()

The loading spinner is likely to disappear while checking the bar on the narrow timeline.

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>

---------

Signed-off-by: Suguru Hirahara <luixxiul@users.noreply.github.com>
Co-authored-by: Kerry <kerrya@element.io>
2023-03-30 10:11:16 +01:00

271 lines
11 KiB
TypeScript

/*
Copyright 2022 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, { useCallback, useContext, useEffect, useState } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import AccessibleButton from "../elements/AccessibleButton";
import { OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload";
import { UserTab } from "../dialogs/UserTab";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
import { SetupEncryptionStore } from "../../../stores/SetupEncryptionStore";
import Spinner from "../elements/Spinner";
interface IProps {
failures: MatrixEvent[];
}
// Number of milliseconds to display a loading spinner before prompting the user for action
const WAIT_PERIOD = 5000;
export const DecryptionFailureBar: React.FC<IProps> = ({ failures }) => {
const context = useContext(MatrixClientContext);
// Display a spinner for a few seconds before presenting an error message,
// in case keys are about to arrive
const [waiting, setWaiting] = useState<boolean>(true);
useEffect(() => {
const timeout = setTimeout(() => setWaiting(false), WAIT_PERIOD);
return () => clearTimeout(timeout);
}, []);
// Is this device unverified?
const [needsVerification, setNeedsVerification] = useState<boolean>(false);
// Does this user have verified devices other than this device?
const [hasOtherVerifiedDevices, setHasOtherVerifiedDevices] = useState<boolean>(false);
// Does this user have key backups?
const [hasKeyBackup, setHasKeyBackup] = useState<boolean>(false);
// Keep track of session IDs that the user has sent key
// requests for using the Resend Key Requests button
const [requestedSessions, setRequestedSessions] = useState<Set<string>>(new Set());
// Keep track of whether there are any sessions the user has not yet sent requests for
const [anyUnrequestedSessions, setAnyUnrequestedSessions] = useState<boolean>(true);
useEffect(() => {
setAnyUnrequestedSessions(
failures.some((event) => {
const sessionId = event.getWireContent().session_id;
return sessionId && !requestedSessions.has(sessionId);
}),
);
}, [failures, requestedSessions, setAnyUnrequestedSessions]);
// Send key requests for any sessions that we haven't previously
// sent requests for. This is important if, for instance, we
// failed to decrypt a message because a key was withheld (in
// which case, we wouldn't bother requesting the key), but have
// since verified our device. In that case, now that the device is
// verified, other devices might be willing to share the key with us
// now.
const sendKeyRequests = useCallback(() => {
const newRequestedSessions = new Set(requestedSessions);
for (const event of failures) {
const sessionId = event.getWireContent().session_id;
if (!sessionId || newRequestedSessions.has(sessionId)) continue;
newRequestedSessions.add(sessionId);
context.cancelAndResendEventRoomKeyRequest(event);
}
setRequestedSessions(newRequestedSessions);
}, [context, requestedSessions, setRequestedSessions, failures]);
// Recheck which devices are verified and whether we have key backups
const updateDeviceInfo = useCallback(async () => {
const deviceId = context.getDeviceId()!;
let verified = true; // if we can't get a clear answer, don't bug the user about verifying
try {
verified = context.checkIfOwnDeviceCrossSigned(deviceId);
} catch (e) {
console.error("Error getting device cross-signing info", e);
}
setNeedsVerification(!verified);
let otherVerifiedDevices = false;
try {
const devices = context.getStoredDevicesForUser(context.getUserId()!);
otherVerifiedDevices = devices.some(
(device) => device.deviceId !== deviceId && context.checkIfOwnDeviceCrossSigned(device.deviceId),
);
} catch (e) {
console.error("Error getting info about other devices", e);
}
setHasOtherVerifiedDevices(otherVerifiedDevices);
let keyBackup = false;
try {
const keys = await context.isSecretStored("m.cross_signing.master");
keyBackup = keys !== null && Object.keys(keys).length > 0;
} catch (e) {
console.error("Error getting info about key backups", e);
}
setHasKeyBackup(keyBackup);
}, [context]);
// Update our device info on initial render, and continue updating
// it whenever the client has an update
useEffect(() => {
updateDeviceInfo().catch(console.error);
context.on(CryptoEvent.DevicesUpdated, updateDeviceInfo);
return () => {
context.off(CryptoEvent.DevicesUpdated, updateDeviceInfo);
};
}, [context, updateDeviceInfo]);
const onVerifyClick = (): void => {
Modal.createDialog(SetupEncryptionDialog);
};
const onDeviceListClick = (): void => {
const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: UserTab.Security };
defaultDispatcher.dispatch(payload);
};
const onResetClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.resetConfirm();
};
const statusIndicator = waiting ? (
<Spinner w={24} h={24} />
) : (
<div className="mx_DecryptionFailureBar_start_status_icon" data-testid="decryption-failure-bar-icon" />
);
let className;
let headline: JSX.Element;
let message: JSX.Element;
let button = <React.Fragment />;
if (waiting) {
className = "mx_DecryptionFailureBar";
headline = <React.Fragment>{_t("Decrypting messages…")}</React.Fragment>;
message = (
<React.Fragment>
{_t("Please wait as we try to decrypt your messages. This may take a few moments.")}
</React.Fragment>
);
} else if (needsVerification) {
if (hasOtherVerifiedDevices || hasKeyBackup) {
className = "mx_DecryptionFailureBar mx_DecryptionFailureBar--withEnd";
headline = <React.Fragment>{_t("Verify this device to access all messages")}</React.Fragment>;
message = (
<React.Fragment>
{_t("This device was unable to decrypt some messages because it has not been verified yet.")}
</React.Fragment>
);
button = (
<AccessibleButton
className="mx_DecryptionFailureBar_end_button"
kind="primary"
onClick={onVerifyClick}
data-testid="decryption-failure-bar-button"
>
{_t("Verify")}
</AccessibleButton>
);
} else {
className = "mx_DecryptionFailureBar mx_DecryptionFailureBar--withEnd";
headline = <React.Fragment>{_t("Reset your keys to prevent future decryption errors")}</React.Fragment>;
message = (
<React.Fragment>
{_t(
"You will not be able to access old undecryptable messages, " +
"but resetting your keys will allow you to receive new messages.",
)}
</React.Fragment>
);
button = (
<AccessibleButton
className="mx_DecryptionFailureBar_end_button"
kind="primary"
onClick={onResetClick}
data-testid="decryption-failure-bar-button"
>
{_t("Reset")}
</AccessibleButton>
);
}
} else if (hasOtherVerifiedDevices) {
className = "mx_DecryptionFailureBar mx_DecryptionFailureBar--withEnd";
headline = <React.Fragment>{_t("Open another device to load encrypted messages")}</React.Fragment>;
message = (
<React.Fragment>
{_t(
"This device is requesting decryption keys from your other devices. " +
"Opening one of your other devices may speed this up.",
)}
</React.Fragment>
);
button = (
<AccessibleButton
className="mx_DecryptionFailureBar_end_button"
kind="primary_outline"
onClick={onDeviceListClick}
data-testid="decryption-failure-bar-button"
>
{_t("View your device list")}
</AccessibleButton>
);
} else {
className = "mx_DecryptionFailureBar";
headline = <React.Fragment>{_t("Some messages could not be decrypted")}</React.Fragment>;
message = (
<React.Fragment>
{_t(
"Unfortunately, there are no other verified devices to request decryption keys from. " +
"Signing in and verifying other devices may help avoid this situation in the future.",
)}
</React.Fragment>
);
}
let keyRequestButton = <React.Fragment />;
if (!needsVerification && hasOtherVerifiedDevices && anyUnrequestedSessions) {
className = "mx_DecryptionFailureBar mx_DecryptionFailureBar--withEnd";
keyRequestButton = (
<AccessibleButton
className="mx_DecryptionFailureBar_end_button"
kind="primary"
onClick={sendKeyRequests}
data-testid="decryption-failure-bar-button"
>
{_t("Resend key requests")}
</AccessibleButton>
);
}
return (
<div className={className}>
<div className="mx_DecryptionFailureBar_start">
<div className="mx_DecryptionFailureBar_start_status">
<div data-testid="decryption-failure-bar-indicator">{statusIndicator}</div>
</div>
<div className="mx_DecryptionFailureBar_start_headline">{headline}</div>
<div className="mx_DecryptionFailureBar_start_message">{message}</div>
</div>
<div className="mx_DecryptionFailureBar_end">
{button}
{keyRequestButton}
</div>
</div>
);
};