Merge branch 'refs/heads/develop' into florianduros/tooltip-update
This commit is contained in:
commit
79cbfafce3
23 changed files with 628 additions and 77 deletions
|
@ -71,7 +71,7 @@
|
||||||
"@matrix-org/emojibase-bindings": "^1.1.2",
|
"@matrix-org/emojibase-bindings": "^1.1.2",
|
||||||
"@matrix-org/matrix-wysiwyg": "2.17.0",
|
"@matrix-org/matrix-wysiwyg": "2.17.0",
|
||||||
"@matrix-org/olm": "3.2.15",
|
"@matrix-org/olm": "3.2.15",
|
||||||
"@matrix-org/react-sdk-module-api": "^2.3.0",
|
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||||
"@matrix-org/spec": "^1.7.0",
|
"@matrix-org/spec": "^1.7.0",
|
||||||
"@sentry/browser": "^7.0.0",
|
"@sentry/browser": "^7.0.0",
|
||||||
"@testing-library/react-hooks": "^8.0.1",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
|
|
|
@ -24,7 +24,7 @@ import { QueryDict } from "matrix-js-sdk/src/utils";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg";
|
import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg";
|
||||||
import SecurityCustomisations from "./customisations/Security";
|
import { ModuleRunner } from "./modules/ModuleRunner";
|
||||||
import EventIndexPeg from "./indexing/EventIndexPeg";
|
import EventIndexPeg from "./indexing/EventIndexPeg";
|
||||||
import createMatrixClient from "./utils/createMatrixClient";
|
import createMatrixClient from "./utils/createMatrixClient";
|
||||||
import Notifier from "./Notifier";
|
import Notifier from "./Notifier";
|
||||||
|
@ -863,7 +863,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
|
||||||
localStorage.setItem("mx_device_id", credentials.deviceId);
|
localStorage.setItem("mx_device_id", credentials.deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
SecurityCustomisations.persistCredentials?.(credentials);
|
ModuleRunner.instance.extensions.cryptoSetup?.persistCredentials(credentials);
|
||||||
|
|
||||||
logger.log(`Session persisted for ${credentials.userId}`);
|
logger.log(`Session persisted for ${credentials.userId}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ import {
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { IMatrixClientCreds } from "./MatrixClientPeg";
|
import { IMatrixClientCreds } from "./MatrixClientPeg";
|
||||||
import SecurityCustomisations from "./customisations/Security";
|
import { ModuleRunner } from "./modules/ModuleRunner";
|
||||||
import { getOidcClientId } from "./utils/oidc/registerClient";
|
import { getOidcClientId } from "./utils/oidc/registerClient";
|
||||||
import { IConfigOptions } from "./IConfigOptions";
|
import { IConfigOptions } from "./IConfigOptions";
|
||||||
import SdkConfig from "./SdkConfig";
|
import SdkConfig from "./SdkConfig";
|
||||||
|
@ -291,7 +291,7 @@ export async function sendLoginRequest(
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
};
|
};
|
||||||
|
|
||||||
SecurityCustomisations.examineLoginResponse?.(data, creds);
|
ModuleRunner.instance.extensions.cryptoSetup.examineLoginResponse(data, creds);
|
||||||
|
|
||||||
return creds;
|
return creds;
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientB
|
||||||
import * as StorageManager from "./utils/StorageManager";
|
import * as StorageManager from "./utils/StorageManager";
|
||||||
import IdentityAuthClient from "./IdentityAuthClient";
|
import IdentityAuthClient from "./IdentityAuthClient";
|
||||||
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from "./SecurityManager";
|
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from "./SecurityManager";
|
||||||
import SecurityCustomisations from "./customisations/Security";
|
import { ModuleRunner } from "./modules/ModuleRunner";
|
||||||
import { SlidingSyncManager } from "./SlidingSyncManager";
|
import { SlidingSyncManager } from "./SlidingSyncManager";
|
||||||
import CryptoStoreTooNewDialog from "./components/views/dialogs/CryptoStoreTooNewDialog";
|
import CryptoStoreTooNewDialog from "./components/views/dialogs/CryptoStoreTooNewDialog";
|
||||||
import { _t, UserFriendlyError } from "./languageHandler";
|
import { _t, UserFriendlyError } from "./languageHandler";
|
||||||
|
@ -463,8 +463,9 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (SecurityCustomisations.getDehydrationKey) {
|
const dehydrationKeyCallback = ModuleRunner.instance.extensions.cryptoSetup.getDehydrationKeyCallback();
|
||||||
opts.cryptoCallbacks!.getDehydrationKey = SecurityCustomisations.getDehydrationKey;
|
if (dehydrationKeyCallback) {
|
||||||
|
opts.cryptoCallbacks!.getDehydrationKey = dehydrationKeyCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.matrixClient = createMatrixClient(opts);
|
this.matrixClient = createMatrixClient(opts);
|
||||||
|
|
|
@ -33,7 +33,7 @@ import { isSecureBackupRequired } from "./utils/WellKnownUtils";
|
||||||
import AccessSecretStorageDialog, { KeyParams } from "./components/views/dialogs/security/AccessSecretStorageDialog";
|
import AccessSecretStorageDialog, { KeyParams } from "./components/views/dialogs/security/AccessSecretStorageDialog";
|
||||||
import RestoreKeyBackupDialog from "./components/views/dialogs/security/RestoreKeyBackupDialog";
|
import RestoreKeyBackupDialog from "./components/views/dialogs/security/RestoreKeyBackupDialog";
|
||||||
import SettingsStore from "./settings/SettingsStore";
|
import SettingsStore from "./settings/SettingsStore";
|
||||||
import SecurityCustomisations from "./customisations/Security";
|
import { ModuleRunner } from "./modules/ModuleRunner";
|
||||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||||
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
|
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
|
||||||
|
|
||||||
|
@ -137,9 +137,9 @@ async function getSecretStorageKey({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
|
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
|
||||||
if (keyFromCustomisations) {
|
if (keyFromCustomisations) {
|
||||||
logger.log("Using key from security customisations (secret storage)");
|
logger.log("CryptoSetupExtension: Using key from extension (secret storage)");
|
||||||
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
|
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
|
||||||
return [keyId, keyFromCustomisations];
|
return [keyId, keyFromCustomisations];
|
||||||
}
|
}
|
||||||
|
@ -187,9 +187,9 @@ export async function getDehydrationKey(
|
||||||
keyInfo: SecretStorage.SecretStorageKeyDescription,
|
keyInfo: SecretStorage.SecretStorageKeyDescription,
|
||||||
checkFunc: (data: Uint8Array) => void,
|
checkFunc: (data: Uint8Array) => void,
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
|
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
|
||||||
if (keyFromCustomisations) {
|
if (keyFromCustomisations) {
|
||||||
logger.log("Using key from security customisations (dehydration)");
|
logger.log("CryptoSetupExtension: Using key from extension (dehydration)");
|
||||||
return keyFromCustomisations;
|
return keyFromCustomisations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -430,7 +430,7 @@ async function doAccessSecretStorage(func: () => Promise<void>, forceReset: bool
|
||||||
// inner operation completes.
|
// inner operation completes.
|
||||||
return await func();
|
return await func();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
SecurityCustomisations.catchAccessSecretStorageError?.(e);
|
ModuleRunner.instance.extensions.cryptoSetup.catchAccessSecretStorageError(e as Error);
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
// Re-throw so that higher level logic can abort as needed
|
// Re-throw so that higher level logic can abort as needed
|
||||||
throw e;
|
throw e;
|
||||||
|
|
|
@ -40,7 +40,7 @@ import {
|
||||||
isSecureBackupRequired,
|
isSecureBackupRequired,
|
||||||
SecureBackupSetupMethod,
|
SecureBackupSetupMethod,
|
||||||
} from "../../../../utils/WellKnownUtils";
|
} from "../../../../utils/WellKnownUtils";
|
||||||
import SecurityCustomisations from "../../../../customisations/Security";
|
import { ModuleRunner } from "../../../../modules/ModuleRunner";
|
||||||
import Field from "../../../../components/views/elements/Field";
|
import Field from "../../../../components/views/elements/Field";
|
||||||
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
|
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
|
||||||
import Spinner from "../../../../components/views/elements/Spinner";
|
import Spinner from "../../../../components/views/elements/Spinner";
|
||||||
|
@ -180,9 +180,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||||
}
|
}
|
||||||
|
|
||||||
private getInitialPhase(): void {
|
private getInitialPhase(): void {
|
||||||
const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.();
|
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey();
|
||||||
if (keyFromCustomisations) {
|
if (keyFromCustomisations) {
|
||||||
logger.log("Created key via customisations, jumping to bootstrap step");
|
logger.log("CryptoSetupExtension: Created key via extension, jumping to bootstrap step");
|
||||||
this.recoveryKey = {
|
this.recoveryKey = {
|
||||||
privateKey: keyFromCustomisations,
|
privateKey: keyFromCustomisations,
|
||||||
};
|
};
|
||||||
|
|
|
@ -87,7 +87,7 @@ import { showToast as showMobileGuideToast } from "../../toasts/MobileGuideToast
|
||||||
import { shouldUseLoginForWelcome } from "../../utils/pages";
|
import { shouldUseLoginForWelcome } from "../../utils/pages";
|
||||||
import RoomListStore from "../../stores/room-list/RoomListStore";
|
import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||||
import { RoomUpdateCause } from "../../stores/room-list/models";
|
import { RoomUpdateCause } from "../../stores/room-list/models";
|
||||||
import SecurityCustomisations from "../../customisations/Security";
|
import { ModuleRunner } from "../../modules/ModuleRunner";
|
||||||
import Spinner from "../views/elements/Spinner";
|
import Spinner from "../views/elements/Spinner";
|
||||||
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
||||||
import UserSettingsDialog from "../views/dialogs/UserSettingsDialog";
|
import UserSettingsDialog from "../views/dialogs/UserSettingsDialog";
|
||||||
|
@ -441,7 +441,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
if (crossSigningIsSetUp) {
|
if (crossSigningIsSetUp) {
|
||||||
// if the user has previously set up cross-signing, verify this device so we can fetch the
|
// if the user has previously set up cross-signing, verify this device so we can fetch the
|
||||||
// private keys.
|
// private keys.
|
||||||
if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) {
|
|
||||||
|
const cryptoExtension = ModuleRunner.instance.extensions.cryptoSetup;
|
||||||
|
if (cryptoExtension.SHOW_ENCRYPTION_SETUP_UI == false) {
|
||||||
this.onLoggedIn();
|
this.onLoggedIn();
|
||||||
} else {
|
} else {
|
||||||
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
|
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
|
||||||
|
|
|
@ -79,7 +79,6 @@ export const CallGuestLinkButton: React.FC<{ room: Room }> = ({ room }) => {
|
||||||
// If the user cannot invite the Knocking is not given as an option.
|
// If the user cannot invite the Knocking is not given as an option.
|
||||||
canInvite,
|
canInvite,
|
||||||
}).finished.then(() => {
|
}).finished.then(() => {
|
||||||
// we need to use the function here because the callback got called before the state was updated.
|
|
||||||
if (isRoomJoinable()) showLinkModal();
|
if (isRoomJoinable()) showLinkModal();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ import { Caption } from "../typography/Caption";
|
||||||
import { SettingsSubsectionHeading } from "./shared/SettingsSubsectionHeading";
|
import { SettingsSubsectionHeading } from "./shared/SettingsSubsectionHeading";
|
||||||
import SettingsSubsection from "./shared/SettingsSubsection";
|
import SettingsSubsection from "./shared/SettingsSubsection";
|
||||||
import { doesRoomHaveUnreadMessages } from "../../../Unread";
|
import { doesRoomHaveUnreadMessages } from "../../../Unread";
|
||||||
|
import SettingsFlag from "../elements/SettingsFlag";
|
||||||
|
|
||||||
// TODO: this "view" component still has far too much application logic in it,
|
// TODO: this "view" component still has far too much application logic in it,
|
||||||
// which should be factored out to other files.
|
// which should be factored out to other files.
|
||||||
|
@ -200,6 +201,18 @@ const maximumVectorState = (
|
||||||
return vectorState;
|
return vectorState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NotificationActivitySettings = (): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} />
|
||||||
|
<SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The old, deprecated notifications tab view, only displayed if the user has the labs flag disabled.
|
||||||
|
*/
|
||||||
export default class Notifications extends React.PureComponent<IProps, IState> {
|
export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||||
private settingWatchers: string[];
|
private settingWatchers: string[];
|
||||||
|
|
||||||
|
@ -731,43 +744,10 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderCategory(category: RuleClass): ReactNode {
|
private renderCategory(category: RuleClass): ReactNode {
|
||||||
if (category !== RuleClass.VectorOther && this.isInhibited) {
|
if (this.isInhibited) {
|
||||||
return null; // nothing to show for the section
|
return null; // nothing to show for the section
|
||||||
}
|
}
|
||||||
|
|
||||||
let clearNotifsButton: JSX.Element | undefined;
|
|
||||||
if (
|
|
||||||
category === RuleClass.VectorOther &&
|
|
||||||
MatrixClientPeg.safeGet()
|
|
||||||
.getRooms()
|
|
||||||
.some((r) => doesRoomHaveUnreadMessages(r, true))
|
|
||||||
) {
|
|
||||||
clearNotifsButton = (
|
|
||||||
<AccessibleButton
|
|
||||||
onClick={this.onClearNotificationsClicked}
|
|
||||||
disabled={this.state.clearingNotifications}
|
|
||||||
kind="danger"
|
|
||||||
className="mx_UserNotifSettings_clearNotifsButton"
|
|
||||||
data-testid="clear-notifications"
|
|
||||||
>
|
|
||||||
{_t("notifications|mark_all_read")}
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category === RuleClass.VectorOther && this.isInhibited) {
|
|
||||||
// only render the utility buttons (if needed)
|
|
||||||
if (clearNotifsButton) {
|
|
||||||
return (
|
|
||||||
<div className="mx_UserNotifSettings_floatingSection">
|
|
||||||
<div>{_t("notifications|class_other")}</div>
|
|
||||||
{clearNotifsButton}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let keywordComposer: JSX.Element | undefined;
|
let keywordComposer: JSX.Element | undefined;
|
||||||
if (category === RuleClass.VectorMentions) {
|
if (category === RuleClass.VectorMentions) {
|
||||||
const tags = filterBoolean<string>(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []);
|
const tags = filterBoolean<string>(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []);
|
||||||
|
@ -842,7 +822,6 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||||
<span className="mx_UserNotifSettings_gridColumnLabel">{VectorStateToLabel[VectorState.Loud]}</span>
|
<span className="mx_UserNotifSettings_gridColumnLabel">{VectorStateToLabel[VectorState.Loud]}</span>
|
||||||
{fieldsetRows}
|
{fieldsetRows}
|
||||||
</div>
|
</div>
|
||||||
{clearNotifsButton}
|
|
||||||
{keywordComposer}
|
{keywordComposer}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -878,6 +857,25 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||||
return <p data-testid="error-message">{_t("settings|notifications|error_loading")}</p>;
|
return <p data-testid="error-message">{_t("settings|notifications|error_loading")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let clearNotifsButton: JSX.Element | undefined;
|
||||||
|
if (
|
||||||
|
MatrixClientPeg.safeGet()
|
||||||
|
.getRooms()
|
||||||
|
.some((r) => doesRoomHaveUnreadMessages(r, true))
|
||||||
|
) {
|
||||||
|
clearNotifsButton = (
|
||||||
|
<AccessibleButton
|
||||||
|
onClick={this.onClearNotificationsClicked}
|
||||||
|
disabled={this.state.clearingNotifications}
|
||||||
|
kind="danger"
|
||||||
|
className="mx_UserNotifSettings_clearNotifsButton"
|
||||||
|
data-testid="clear-notifications"
|
||||||
|
>
|
||||||
|
{_t("notifications|mark_all_read")}
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{this.renderTopSection()}
|
{this.renderTopSection()}
|
||||||
|
@ -885,6 +883,8 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||||
{this.renderCategory(RuleClass.VectorMentions)}
|
{this.renderCategory(RuleClass.VectorMentions)}
|
||||||
{this.renderCategory(RuleClass.VectorOther)}
|
{this.renderCategory(RuleClass.VectorOther)}
|
||||||
{this.renderTargets()}
|
{this.renderTargets()}
|
||||||
|
<NotificationActivitySettings />
|
||||||
|
{clearNotifsButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ import { SettingsBanner } from "../shared/SettingsBanner";
|
||||||
import { SettingsSection } from "../shared/SettingsSection";
|
import { SettingsSection } from "../shared/SettingsSection";
|
||||||
import SettingsSubsection from "../shared/SettingsSubsection";
|
import SettingsSubsection from "../shared/SettingsSubsection";
|
||||||
import { NotificationPusherSettings } from "./NotificationPusherSettings";
|
import { NotificationPusherSettings } from "./NotificationPusherSettings";
|
||||||
|
import SettingsFlag from "../../elements/SettingsFlag";
|
||||||
|
|
||||||
enum NotificationDefaultLevels {
|
enum NotificationDefaultLevels {
|
||||||
AllMessages = "all_messages",
|
AllMessages = "all_messages",
|
||||||
|
@ -71,6 +72,9 @@ function useHasUnreadNotifications(): boolean {
|
||||||
return cli.getRooms().some((room) => room.getUnreadNotificationCount() > 0);
|
return cli.getRooms().some((room) => room.getUnreadNotificationCount() > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new notification settings tab view, only displayed if the user has Features.NotificationSettings2 enabled
|
||||||
|
*/
|
||||||
export default function NotificationSettings2(): JSX.Element {
|
export default function NotificationSettings2(): JSX.Element {
|
||||||
const cli = useMatrixClientContext();
|
const cli = useMatrixClientContext();
|
||||||
|
|
||||||
|
@ -352,6 +356,9 @@ export default function NotificationSettings2(): JSX.Element {
|
||||||
label={_t("notifications|keyword")}
|
label={_t("notifications|keyword")}
|
||||||
placeholder={_t("notifications|keyword_new")}
|
placeholder={_t("notifications|keyword_new")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} />
|
||||||
|
<SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} />
|
||||||
</SettingsSubsection>
|
</SettingsSubsection>
|
||||||
<NotificationPusherSettings />
|
<NotificationPusherSettings />
|
||||||
<SettingsSubsection heading={_t("settings|notifications|quick_actions_section")}>
|
<SettingsSubsection heading={_t("settings|notifications|quick_actions_section")}>
|
||||||
|
|
|
@ -43,13 +43,14 @@ type Result = {
|
||||||
*/
|
*/
|
||||||
export function useUnreadThreadRooms(forceComputation: boolean): Result {
|
export function useUnreadThreadRooms(forceComputation: boolean): Result {
|
||||||
const msc3946ProcessDynamicPredecessor = useSettingValue<boolean>("feature_dynamic_room_predecessors");
|
const msc3946ProcessDynamicPredecessor = useSettingValue<boolean>("feature_dynamic_room_predecessors");
|
||||||
|
const settingTACOnlyNotifs = useSettingValue<boolean>("Notifications.tac_only_notifications");
|
||||||
const mxClient = useMatrixClientContext();
|
const mxClient = useMatrixClientContext();
|
||||||
|
|
||||||
const [result, setResult] = useState<Result>({ greatestNotificationLevel: NotificationLevel.None, rooms: [] });
|
const [result, setResult] = useState<Result>({ greatestNotificationLevel: NotificationLevel.None, rooms: [] });
|
||||||
|
|
||||||
const doUpdate = useCallback(() => {
|
const doUpdate = useCallback(() => {
|
||||||
setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor));
|
setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs));
|
||||||
}, [mxClient, msc3946ProcessDynamicPredecessor]);
|
}, [mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs]);
|
||||||
|
|
||||||
// The exhautive deps lint rule can't compute dependencies here since it's not a plain inline func.
|
// The exhautive deps lint rule can't compute dependencies here since it's not a plain inline func.
|
||||||
// We make this as simple as possible so its only dep is doUpdate itself.
|
// We make this as simple as possible so its only dep is doUpdate itself.
|
||||||
|
@ -83,7 +84,11 @@ export function useUnreadThreadRooms(forceComputation: boolean): Result {
|
||||||
* @param mxClient - MatrixClient
|
* @param mxClient - MatrixClient
|
||||||
* @param msc3946ProcessDynamicPredecessor
|
* @param msc3946ProcessDynamicPredecessor
|
||||||
*/
|
*/
|
||||||
function computeUnreadThreadRooms(mxClient: MatrixClient, msc3946ProcessDynamicPredecessor: boolean): Result {
|
function computeUnreadThreadRooms(
|
||||||
|
mxClient: MatrixClient,
|
||||||
|
msc3946ProcessDynamicPredecessor: boolean,
|
||||||
|
settingTACOnlyNotifs: boolean,
|
||||||
|
): Result {
|
||||||
// Only count visible rooms to not torment the user with notification counts in rooms they can't see.
|
// Only count visible rooms to not torment the user with notification counts in rooms they can't see.
|
||||||
// This will include highlights from the previous version of the room internally
|
// This will include highlights from the previous version of the room internally
|
||||||
const visibleRooms = mxClient.getVisibleRooms(msc3946ProcessDynamicPredecessor);
|
const visibleRooms = mxClient.getVisibleRooms(msc3946ProcessDynamicPredecessor);
|
||||||
|
@ -98,7 +103,7 @@ function computeUnreadThreadRooms(mxClient: MatrixClient, msc3946ProcessDynamicP
|
||||||
const notificationLevel = getThreadNotificationLevel(room);
|
const notificationLevel = getThreadNotificationLevel(room);
|
||||||
|
|
||||||
// If the room has an activity notification or less, we ignore it
|
// If the room has an activity notification or less, we ignore it
|
||||||
if (notificationLevel <= NotificationLevel.Activity) {
|
if (settingTACOnlyNotifs && notificationLevel <= NotificationLevel.Activity) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,10 @@ export const useGuestAccessInformation = (room: Room): GuestAccessInformation =>
|
||||||
[canChangeJoinRule, isRoomJoinable, guestSpaUrl],
|
[canChangeJoinRule, isRoomJoinable, guestSpaUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isRoomJoinableFunction = (): boolean =>
|
const isRoomJoinableFunction = (): boolean => {
|
||||||
room.getJoinRule() === JoinRule.Public || (joinRule === JoinRule.Knock && room.canInvite(room.myUserId));
|
const join = room.getJoinRule();
|
||||||
|
return join === JoinRule.Public || (join === JoinRule.Knock && room.canInvite(room.myUserId));
|
||||||
|
};
|
||||||
|
|
||||||
return { canInviteGuests, guestSpaUrl, isRoomJoinable: isRoomJoinableFunction, canInvite };
|
return { canInviteGuests, guestSpaUrl, isRoomJoinable: isRoomJoinableFunction, canInvite };
|
||||||
};
|
};
|
||||||
|
|
|
@ -2846,6 +2846,7 @@
|
||||||
"show_redaction_placeholder": "Show a placeholder for removed messages",
|
"show_redaction_placeholder": "Show a placeholder for removed messages",
|
||||||
"show_stickers_button": "Show stickers button",
|
"show_stickers_button": "Show stickers button",
|
||||||
"show_typing_notifications": "Show typing notifications",
|
"show_typing_notifications": "Show typing notifications",
|
||||||
|
"showbold": "Show all activity in the room list (dots or number of unread messages)",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"metaspaces_favourites_description": "Group all your favourite rooms and people in one place.",
|
"metaspaces_favourites_description": "Group all your favourite rooms and people in one place.",
|
||||||
"metaspaces_home_all_rooms": "Show all rooms",
|
"metaspaces_home_all_rooms": "Show all rooms",
|
||||||
|
@ -2862,6 +2863,7 @@
|
||||||
"title": "Sidebar"
|
"title": "Sidebar"
|
||||||
},
|
},
|
||||||
"start_automatically": "Start automatically after system login",
|
"start_automatically": "Start automatically after system login",
|
||||||
|
"tac_only_notifications": "Only show notifications in the thread activity centre",
|
||||||
"use_12_hour_format": "Show timestamps in 12 hour format (e.g. 2:30pm)",
|
"use_12_hour_format": "Show timestamps in 12 hour format (e.g. 2:30pm)",
|
||||||
"use_command_enter_send_message": "Use Command + Enter to send a message",
|
"use_command_enter_send_message": "Use Command + Enter to send a message",
|
||||||
"use_command_f_search": "Use Command + F to search timeline",
|
"use_command_f_search": "Use Command + F to search timeline",
|
||||||
|
|
|
@ -17,18 +17,108 @@ limitations under the License.
|
||||||
import { safeSet } from "matrix-js-sdk/src/utils";
|
import { safeSet } from "matrix-js-sdk/src/utils";
|
||||||
import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations";
|
import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations";
|
||||||
import { AnyLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/types";
|
import { AnyLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/types";
|
||||||
|
import {
|
||||||
|
DefaultCryptoSetupExtensions,
|
||||||
|
ProvideCryptoSetupExtensions,
|
||||||
|
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions";
|
||||||
|
import {
|
||||||
|
DefaultExperimentalExtensions,
|
||||||
|
ProvideExperimentalExtensions,
|
||||||
|
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/ExperimentalExtensions";
|
||||||
|
|
||||||
import { AppModule } from "./AppModule";
|
import { AppModule } from "./AppModule";
|
||||||
import { ModuleFactory } from "./ModuleFactory";
|
import { ModuleFactory } from "./ModuleFactory";
|
||||||
|
|
||||||
import "./ModuleComponents";
|
import "./ModuleComponents";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles and manages extensions provided by modules.
|
||||||
|
*/
|
||||||
|
class ExtensionsManager {
|
||||||
|
// Private backing fields for extensions
|
||||||
|
private cryptoSetupExtension: ProvideCryptoSetupExtensions;
|
||||||
|
private experimentalExtension: ProvideExperimentalExtensions;
|
||||||
|
|
||||||
|
/** `true` if `cryptoSetupExtension` is the default implementation; `false` if it is implemented by a module. */
|
||||||
|
private hasDefaultCryptoSetupExtension = true;
|
||||||
|
|
||||||
|
/** `true` if `experimentalExtension` is the default implementation; `false` if it is implemented by a module. */
|
||||||
|
private hasDefaultExperimentalExtension = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new instance.
|
||||||
|
*/
|
||||||
|
public constructor() {
|
||||||
|
// Set up defaults
|
||||||
|
this.cryptoSetupExtension = new DefaultCryptoSetupExtensions();
|
||||||
|
this.experimentalExtension = new DefaultExperimentalExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a crypto setup extension.
|
||||||
|
*
|
||||||
|
* @returns The registered extension. If no module provides this extension, a default implementation is returned.
|
||||||
|
*/
|
||||||
|
public get cryptoSetup(): ProvideCryptoSetupExtensions {
|
||||||
|
return this.cryptoSetupExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides an experimental extension.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This method extension is provided to simplify experimentation and development, and is not intended for production code.
|
||||||
|
*
|
||||||
|
* @returns The registered extension. If no module provides this extension, a default implementation is returned.
|
||||||
|
*/
|
||||||
|
public get experimental(): ProvideExperimentalExtensions {
|
||||||
|
return this.experimentalExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add any extensions provided by the module.
|
||||||
|
*
|
||||||
|
* @param module - The appModule to check for extensions.
|
||||||
|
*
|
||||||
|
* @throws if an extension is provided by more than one module.
|
||||||
|
*/
|
||||||
|
public addExtensions(module: AppModule): void {
|
||||||
|
const runtimeModule = module.module;
|
||||||
|
|
||||||
|
/* Add the cryptoSetup extension if any */
|
||||||
|
if (runtimeModule.extensions?.cryptoSetup) {
|
||||||
|
if (this.hasDefaultCryptoSetupExtension) {
|
||||||
|
this.cryptoSetupExtension = runtimeModule.extensions?.cryptoSetup;
|
||||||
|
this.hasDefaultCryptoSetupExtension = false;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`adding cryptoSetup extension implementation from module ${runtimeModule.moduleName} but an implementation was already provided.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add the experimental extension if any */
|
||||||
|
if (runtimeModule.extensions?.experimental) {
|
||||||
|
if (this.hasDefaultExperimentalExtension) {
|
||||||
|
this.experimentalExtension = runtimeModule.extensions?.experimental;
|
||||||
|
this.hasDefaultExperimentalExtension = false;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`adding experimental extension implementation from module ${runtimeModule.moduleName} but an implementation was already provided.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles and coordinates the operation of modules.
|
* Handles and coordinates the operation of modules.
|
||||||
*/
|
*/
|
||||||
export class ModuleRunner {
|
export class ModuleRunner {
|
||||||
public static readonly instance = new ModuleRunner();
|
public static readonly instance = new ModuleRunner();
|
||||||
|
|
||||||
|
private extensionsManager = new ExtensionsManager();
|
||||||
|
|
||||||
private modules: AppModule[] = [];
|
private modules: AppModule[] = [];
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
|
@ -36,12 +126,22 @@ export class ModuleRunner {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets the runner, clearing all known modules.
|
* Exposes all extensions which may be overridden/provided by modules.
|
||||||
|
*
|
||||||
|
* @returns An `ExtensionsManager` which exposes the extensions.
|
||||||
|
*/
|
||||||
|
public get extensions(): ExtensionsManager {
|
||||||
|
return this.extensionsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the runner, clearing all known modules, and all extensions
|
||||||
*
|
*
|
||||||
* Intended for test usage only.
|
* Intended for test usage only.
|
||||||
*/
|
*/
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.modules = [];
|
this.modules = [];
|
||||||
|
this.extensionsManager = new ExtensionsManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -72,7 +172,12 @@ export class ModuleRunner {
|
||||||
* @param factory The module factory.
|
* @param factory The module factory.
|
||||||
*/
|
*/
|
||||||
public registerModule(factory: ModuleFactory): void {
|
public registerModule(factory: ModuleFactory): void {
|
||||||
this.modules.push(new AppModule(factory));
|
const appModule = new AppModule(factory);
|
||||||
|
|
||||||
|
this.modules.push(appModule);
|
||||||
|
|
||||||
|
// Check if the new module provides any extensions, and also ensure a given extension is only provided by a single runtime module.
|
||||||
|
this.extensionsManager.addExtensions(appModule);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2017 Travis Ralston
|
Copyright 2017 Travis Ralston
|
||||||
Copyright 2018 - 2023 The Matrix.org Foundation C.I.C.
|
Copyright 2018 - 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -586,14 +586,23 @@ export const SETTINGS: { [setting: string]: ISetting } = {
|
||||||
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
|
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
// Used to be a feature, name kept for backwards compat
|
||||||
"feature_hidebold": {
|
"feature_hidebold": {
|
||||||
isFeature: true,
|
|
||||||
labsGroup: LabGroup.Rooms,
|
|
||||||
configDisablesSetting: true,
|
|
||||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
|
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
|
||||||
displayName: _td("labs|hidebold"),
|
displayName: _td("labs|hidebold"),
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
"Notifications.showbold": {
|
||||||
|
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
|
||||||
|
displayName: _td("settings|showbold"),
|
||||||
|
default: false,
|
||||||
|
invertedSettingName: "feature_hidebold",
|
||||||
|
},
|
||||||
|
"Notifications.tac_only_notifications": {
|
||||||
|
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
|
||||||
|
displayName: _td("settings|tac_only_notifications"),
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
"feature_ask_to_join": {
|
"feature_ask_to_join": {
|
||||||
isFeature: true,
|
isFeature: true,
|
||||||
labsGroup: LabGroup.Rooms,
|
labsGroup: LabGroup.Rooms,
|
||||||
|
|
|
@ -21,7 +21,8 @@ import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEnc
|
||||||
import { accessSecretStorage } from "../SecurityManager";
|
import { accessSecretStorage } from "../SecurityManager";
|
||||||
import ToastStore from "../stores/ToastStore";
|
import ToastStore from "../stores/ToastStore";
|
||||||
import GenericToast from "../components/views/toasts/GenericToast";
|
import GenericToast from "../components/views/toasts/GenericToast";
|
||||||
import SecurityCustomisations from "../customisations/Security";
|
import { ModuleRunner } from "../modules/ModuleRunner";
|
||||||
|
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
|
||||||
import Spinner from "../components/views/elements/Spinner";
|
import Spinner from "../components/views/elements/Spinner";
|
||||||
|
|
||||||
const TOAST_KEY = "setupencryption";
|
const TOAST_KEY = "setupencryption";
|
||||||
|
@ -79,7 +80,12 @@ const onReject = (): void => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const showToast = (kind: Kind): void => {
|
export const showToast = (kind: Kind): void => {
|
||||||
if (SecurityCustomisations.setupEncryptionNeeded?.(kind)) {
|
if (
|
||||||
|
ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({
|
||||||
|
kind: kind as any,
|
||||||
|
storeProvider: { getInstance: () => SetupEncryptionStore.sharedInstance() },
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,10 @@ limitations under the License.
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import fetchMockJest from "fetch-mock-jest";
|
import fetchMockJest from "fetch-mock-jest";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
|
import {
|
||||||
|
ProvideCryptoSetupExtensions,
|
||||||
|
SecretStorageKeyDescription,
|
||||||
|
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions";
|
||||||
|
|
||||||
import { advanceDateAndTime, stubClient } from "./test-utils";
|
import { advanceDateAndTime, stubClient } from "./test-utils";
|
||||||
import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg";
|
import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg";
|
||||||
|
@ -25,6 +29,7 @@ import Modal from "../src/Modal";
|
||||||
import PlatformPeg from "../src/PlatformPeg";
|
import PlatformPeg from "../src/PlatformPeg";
|
||||||
import { SettingLevel } from "../src/settings/SettingLevel";
|
import { SettingLevel } from "../src/settings/SettingLevel";
|
||||||
import { Features } from "../src/settings/Settings";
|
import { Features } from "../src/settings/Settings";
|
||||||
|
import { ModuleRunner } from "../src/modules/ModuleRunner";
|
||||||
|
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
@ -77,6 +82,78 @@ describe("MatrixClientPeg", () => {
|
||||||
expect(peg.userRegisteredWithinLastHours(24)).toBe(false);
|
expect(peg.userRegisteredWithinLastHours(24)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(".start extensions", () => {
|
||||||
|
let testPeg: IMatrixClientPeg;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// instantiate a MatrixClientPegClass instance, with a new MatrixClient
|
||||||
|
testPeg = new PegClass();
|
||||||
|
fetchMockJest.get("http://example.com/_matrix/client/versions", {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cryptoSetup extension", () => {
|
||||||
|
it("should call default cryptoSetup.getDehydrationKeyCallback", async () => {
|
||||||
|
const mockCryptoSetup = {
|
||||||
|
SHOW_ENCRYPTION_SETUP_UI: true,
|
||||||
|
examineLoginResponse: jest.fn(),
|
||||||
|
persistCredentials: jest.fn(),
|
||||||
|
getSecretStorageKey: jest.fn(),
|
||||||
|
createSecretStorageKey: jest.fn(),
|
||||||
|
catchAccessSecretStorageError: jest.fn(),
|
||||||
|
setupEncryptionNeeded: jest.fn(),
|
||||||
|
getDehydrationKeyCallback: jest.fn().mockReturnValue(null),
|
||||||
|
} as ProvideCryptoSetupExtensions;
|
||||||
|
|
||||||
|
// Ensure we have an instance before we set up spies
|
||||||
|
const instance = ModuleRunner.instance;
|
||||||
|
jest.spyOn(instance.extensions, "cryptoSetup", "get").mockReturnValue(mockCryptoSetup);
|
||||||
|
|
||||||
|
testPeg.replaceUsingCreds({
|
||||||
|
accessToken: "SEKRET",
|
||||||
|
homeserverUrl: "http://example.com",
|
||||||
|
userId: "@user:example.com",
|
||||||
|
deviceId: "TEST_DEVICE_ID",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCryptoSetup.getDehydrationKeyCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call overridden cryptoSetup.getDehydrationKeyCallback", async () => {
|
||||||
|
const mockDehydrationKeyCallback = () => Uint8Array.from([0x11, 0x22, 0x33]);
|
||||||
|
|
||||||
|
const mockCryptoSetup = {
|
||||||
|
SHOW_ENCRYPTION_SETUP_UI: true,
|
||||||
|
examineLoginResponse: jest.fn(),
|
||||||
|
persistCredentials: jest.fn(),
|
||||||
|
getSecretStorageKey: jest.fn(),
|
||||||
|
createSecretStorageKey: jest.fn(),
|
||||||
|
catchAccessSecretStorageError: jest.fn(),
|
||||||
|
setupEncryptionNeeded: jest.fn(),
|
||||||
|
getDehydrationKeyCallback: jest.fn().mockReturnValue(mockDehydrationKeyCallback),
|
||||||
|
} as ProvideCryptoSetupExtensions;
|
||||||
|
|
||||||
|
// Ensure we have an instance before we set up spies
|
||||||
|
const instance = ModuleRunner.instance;
|
||||||
|
jest.spyOn(instance.extensions, "cryptoSetup", "get").mockReturnValue(mockCryptoSetup);
|
||||||
|
|
||||||
|
testPeg.replaceUsingCreds({
|
||||||
|
accessToken: "SEKRET",
|
||||||
|
homeserverUrl: "http://example.com",
|
||||||
|
userId: "@user:example.com",
|
||||||
|
deviceId: "TEST_DEVICE_ID",
|
||||||
|
});
|
||||||
|
expect(mockCryptoSetup.getDehydrationKeyCallback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const client = testPeg.get();
|
||||||
|
const dehydrationKey = await client?.cryptoCallbacks.getDehydrationKey!(
|
||||||
|
{} as SecretStorageKeyDescription,
|
||||||
|
(key: Uint8Array) => true,
|
||||||
|
);
|
||||||
|
expect(dehydrationKey).toEqual(Uint8Array.from([0x11, 0x22, 0x33]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe(".start", () => {
|
describe(".start", () => {
|
||||||
let testPeg: IMatrixClientPeg;
|
let testPeg: IMatrixClientPeg;
|
||||||
|
|
||||||
|
|
|
@ -35,5 +35,61 @@ exports[`<Notifications /> main notification switches renders only enable notifi
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_SettingsFlag"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="mx_SettingsFlag_label"
|
||||||
|
for="mx_SettingsFlag_testid_1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mx_SettingsFlag_labelText"
|
||||||
|
>
|
||||||
|
Show all activity in the room list (dots or number of unread messages)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
aria-checked="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-label="Show all activity in the room list (dots or number of unread messages)"
|
||||||
|
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
|
||||||
|
id="mx_SettingsFlag_testid_1"
|
||||||
|
role="switch"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_ToggleSwitch_ball"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_SettingsFlag"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="mx_SettingsFlag_label"
|
||||||
|
for="mx_SettingsFlag_testid_2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mx_SettingsFlag_labelText"
|
||||||
|
>
|
||||||
|
Only show notifications in the thread activity centre
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
aria-checked="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-label="Only show notifications in the thread activity centre"
|
||||||
|
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
|
||||||
|
id="mx_SettingsFlag_testid_2"
|
||||||
|
role="switch"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_ToggleSwitch_ball"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -641,6 +641,60 @@ exports[`<Notifications /> correctly handles the loading/disabled state 1`] = `
|
||||||
role="list"
|
role="list"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_SettingsFlag"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="mx_SettingsFlag_label"
|
||||||
|
for="mx_SettingsFlag_QRlYy75nfv5b"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mx_SettingsFlag_labelText"
|
||||||
|
>
|
||||||
|
Show all activity in the room list (dots or number of unread messages)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
aria-checked="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-label="Show all activity in the room list (dots or number of unread messages)"
|
||||||
|
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
|
||||||
|
id="mx_SettingsFlag_QRlYy75nfv5b"
|
||||||
|
role="switch"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_ToggleSwitch_ball"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_SettingsFlag"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="mx_SettingsFlag_label"
|
||||||
|
for="mx_SettingsFlag_OEPN1su1JYVt"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mx_SettingsFlag_labelText"
|
||||||
|
>
|
||||||
|
Only show notifications in the thread activity centre
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
aria-checked="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-label="Only show notifications in the thread activity centre"
|
||||||
|
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
|
||||||
|
id="mx_SettingsFlag_OEPN1su1JYVt"
|
||||||
|
role="switch"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_ToggleSwitch_ball"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -1472,6 +1526,60 @@ exports[`<Notifications /> matches the snapshot 1`] = `
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_SettingsFlag"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="mx_SettingsFlag_label"
|
||||||
|
for="mx_SettingsFlag_QRlYy75nfv5b"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mx_SettingsFlag_labelText"
|
||||||
|
>
|
||||||
|
Show all activity in the room list (dots or number of unread messages)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
aria-checked="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-label="Show all activity in the room list (dots or number of unread messages)"
|
||||||
|
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
|
||||||
|
id="mx_SettingsFlag_QRlYy75nfv5b"
|
||||||
|
role="switch"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_ToggleSwitch_ball"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_SettingsFlag"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="mx_SettingsFlag_label"
|
||||||
|
for="mx_SettingsFlag_OEPN1su1JYVt"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mx_SettingsFlag_labelText"
|
||||||
|
>
|
||||||
|
Only show notifications in the thread activity centre
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
aria-checked="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-label="Only show notifications in the thread activity centre"
|
||||||
|
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
|
||||||
|
id="mx_SettingsFlag_OEPN1su1JYVt"
|
||||||
|
role="switch"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_ToggleSwitch_ball"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -1523,11 +1631,11 @@ exports[`<Notifications /> matches the snapshot 1`] = `
|
||||||
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
|
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="checkbox_QRlYy75nfv"
|
id="checkbox_OyR5kbu3pE"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
for="checkbox_QRlYy75nfv"
|
for="checkbox_OyR5kbu3pE"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_Checkbox_background"
|
class="mx_Checkbox_background"
|
||||||
|
|
|
@ -30,6 +30,7 @@ import { stubClient } from "../../../test-utils";
|
||||||
import { populateThread } from "../../../test-utils/threads";
|
import { populateThread } from "../../../test-utils/threads";
|
||||||
import { NotificationLevel } from "../../../../src/stores/notifications/NotificationLevel";
|
import { NotificationLevel } from "../../../../src/stores/notifications/NotificationLevel";
|
||||||
import { useUnreadThreadRooms } from "../../../../src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms";
|
import { useUnreadThreadRooms } from "../../../../src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms";
|
||||||
|
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||||
|
|
||||||
describe("useUnreadThreadRooms", () => {
|
describe("useUnreadThreadRooms", () => {
|
||||||
let client: MatrixClient;
|
let client: MatrixClient;
|
||||||
|
@ -43,6 +44,10 @@ describe("useUnreadThreadRooms", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("has no notifications with no rooms", async () => {
|
it("has no notifications with no rooms", async () => {
|
||||||
const { result } = renderHook(() => useUnreadThreadRooms(false));
|
const { result } = renderHook(() => useUnreadThreadRooms(false));
|
||||||
const { greatestNotificationLevel, rooms } = result.current;
|
const { greatestNotificationLevel, rooms } = result.current;
|
||||||
|
@ -51,7 +56,7 @@ describe("useUnreadThreadRooms", () => {
|
||||||
expect(rooms.length).toEqual(0);
|
expect(rooms.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an activity notification is ignored", async () => {
|
it("an activity notification is ignored by default", async () => {
|
||||||
const notifThreadInfo = await populateThread({
|
const notifThreadInfo = await populateThread({
|
||||||
room: room,
|
room: room,
|
||||||
client: client,
|
client: client,
|
||||||
|
@ -73,6 +78,30 @@ describe("useUnreadThreadRooms", () => {
|
||||||
expect(rooms.length).toEqual(0);
|
expect(rooms.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an activity notification is displayed with the setting enabled", async () => {
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||||
|
|
||||||
|
const notifThreadInfo = await populateThread({
|
||||||
|
room: room,
|
||||||
|
client: client,
|
||||||
|
authorId: "@foo:bar",
|
||||||
|
participantUserIds: ["@fee:bar"],
|
||||||
|
});
|
||||||
|
room.setThreadUnreadNotificationCount(notifThreadInfo.thread.id, NotificationCountType.Total, 0);
|
||||||
|
|
||||||
|
client.getVisibleRooms = jest.fn().mockReturnValue([room]);
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUnreadThreadRooms(true), { wrapper });
|
||||||
|
const { greatestNotificationLevel, rooms } = result.current;
|
||||||
|
|
||||||
|
expect(greatestNotificationLevel).toBe(NotificationLevel.Activity);
|
||||||
|
expect(rooms.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("a notification and a highlight summarise to a highlight", async () => {
|
it("a notification and a highlight summarise to a highlight", async () => {
|
||||||
const notifThreadInfo = await populateThread({
|
const notifThreadInfo = await populateThread({
|
||||||
room: room,
|
room: room,
|
||||||
|
|
|
@ -16,6 +16,9 @@ limitations under the License.
|
||||||
|
|
||||||
import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
|
import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
|
||||||
import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi";
|
import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi";
|
||||||
|
import { AllExtensions } from "@matrix-org/react-sdk-module-api/lib/types/extensions";
|
||||||
|
import { ProvideCryptoSetupExtensions } from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions";
|
||||||
|
import { ProvideExperimentalExtensions } from "@matrix-org/react-sdk-module-api/lib/lifecycles/ExperimentalExtensions";
|
||||||
|
|
||||||
import { ModuleRunner } from "../../src/modules/ModuleRunner";
|
import { ModuleRunner } from "../../src/modules/ModuleRunner";
|
||||||
|
|
||||||
|
@ -29,6 +32,11 @@ export class MockModule extends RuntimeModule {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a mock module
|
||||||
|
*
|
||||||
|
* @returns The registered module.
|
||||||
|
*/
|
||||||
export function registerMockModule(): MockModule {
|
export function registerMockModule(): MockModule {
|
||||||
let module: MockModule | undefined;
|
let module: MockModule | undefined;
|
||||||
ModuleRunner.instance.registerModule((api) => {
|
ModuleRunner.instance.registerModule((api) => {
|
||||||
|
@ -43,3 +51,88 @@ export function registerMockModule(): MockModule {
|
||||||
}
|
}
|
||||||
return module;
|
return module;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockModuleWithCryptoSetupExtension extends RuntimeModule {
|
||||||
|
public get apiInstance(): ModuleApi {
|
||||||
|
return this.moduleApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleName: string = MockModuleWithCryptoSetupExtension.name;
|
||||||
|
|
||||||
|
extensions: AllExtensions = {
|
||||||
|
cryptoSetup: {
|
||||||
|
SHOW_ENCRYPTION_SETUP_UI: true,
|
||||||
|
examineLoginResponse: jest.fn(),
|
||||||
|
persistCredentials: jest.fn(),
|
||||||
|
getSecretStorageKey: jest.fn().mockReturnValue(Uint8Array.from([0x11, 0x22, 0x99])),
|
||||||
|
createSecretStorageKey: jest.fn(),
|
||||||
|
catchAccessSecretStorageError: jest.fn(),
|
||||||
|
setupEncryptionNeeded: jest.fn(),
|
||||||
|
getDehydrationKeyCallback: jest.fn(),
|
||||||
|
} as ProvideCryptoSetupExtensions,
|
||||||
|
};
|
||||||
|
|
||||||
|
public constructor(moduleApi: ModuleApi) {
|
||||||
|
super(moduleApi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockModuleWithExperimentalExtension extends RuntimeModule {
|
||||||
|
public get apiInstance(): ModuleApi {
|
||||||
|
return this.moduleApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleName: string = MockModuleWithExperimentalExtension.name;
|
||||||
|
|
||||||
|
extensions: AllExtensions = {
|
||||||
|
experimental: {
|
||||||
|
experimentalMethod: jest.fn().mockReturnValue(Uint8Array.from([0x22, 0x44, 0x88])),
|
||||||
|
} as ProvideExperimentalExtensions,
|
||||||
|
};
|
||||||
|
|
||||||
|
public constructor(moduleApi: ModuleApi) {
|
||||||
|
super(moduleApi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a mock module which implements the cryptoSetup extension.
|
||||||
|
*
|
||||||
|
* @returns The registered module.
|
||||||
|
*/
|
||||||
|
export function registerMockModuleWithCryptoSetupExtension(): MockModuleWithCryptoSetupExtension {
|
||||||
|
let module: MockModuleWithCryptoSetupExtension | undefined;
|
||||||
|
|
||||||
|
ModuleRunner.instance.registerModule((api) => {
|
||||||
|
if (module) {
|
||||||
|
throw new Error("State machine error: ModuleRunner created the module twice");
|
||||||
|
}
|
||||||
|
module = new MockModuleWithCryptoSetupExtension(api);
|
||||||
|
return module;
|
||||||
|
});
|
||||||
|
if (!module) {
|
||||||
|
throw new Error("State machine error: ModuleRunner did not create module");
|
||||||
|
}
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a mock module which implements the experimental extension.
|
||||||
|
*
|
||||||
|
* @returns The registered module.
|
||||||
|
*/
|
||||||
|
export function registerMockModuleWithExperimentalExtension(): MockModuleWithExperimentalExtension {
|
||||||
|
let module: MockModuleWithExperimentalExtension | undefined;
|
||||||
|
|
||||||
|
ModuleRunner.instance.registerModule((api) => {
|
||||||
|
if (module) {
|
||||||
|
throw new Error("State machine error: ModuleRunner created the module twice");
|
||||||
|
}
|
||||||
|
module = new MockModuleWithExperimentalExtension(api);
|
||||||
|
return module;
|
||||||
|
});
|
||||||
|
if (!module) {
|
||||||
|
throw new Error("State machine error: ModuleRunner did not create module");
|
||||||
|
}
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
|
|
@ -16,7 +16,12 @@ limitations under the License.
|
||||||
|
|
||||||
import { RoomPreviewOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
import { RoomPreviewOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||||
|
|
||||||
import { MockModule, registerMockModule } from "./MockModule";
|
import {
|
||||||
|
MockModule,
|
||||||
|
registerMockModule,
|
||||||
|
registerMockModuleWithCryptoSetupExtension,
|
||||||
|
registerMockModuleWithExperimentalExtension,
|
||||||
|
} from "./MockModule";
|
||||||
import { ModuleRunner } from "../../src/modules/ModuleRunner";
|
import { ModuleRunner } from "../../src/modules/ModuleRunner";
|
||||||
|
|
||||||
describe("ModuleRunner", () => {
|
describe("ModuleRunner", () => {
|
||||||
|
@ -49,4 +54,48 @@ describe("ModuleRunner", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extensions", () => {
|
||||||
|
it("should return default values when no crypto-setup extensions are provided by a registered module", async () => {
|
||||||
|
registerMockModule();
|
||||||
|
const result = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default values when no experimental extensions are provided by a registered module", async () => {
|
||||||
|
registerMockModule();
|
||||||
|
const result = ModuleRunner.instance.extensions?.experimental.experimentalMethod();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return value from crypto-setup-extensions provided by a registered module", async () => {
|
||||||
|
registerMockModuleWithCryptoSetupExtension();
|
||||||
|
const result = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
|
||||||
|
expect(result).toEqual(Uint8Array.from([0x11, 0x22, 0x99]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return value from experimental-extensions provided by a registered module", async () => {
|
||||||
|
registerMockModuleWithExperimentalExtension();
|
||||||
|
const result = ModuleRunner.instance.extensions.experimental.experimentalMethod();
|
||||||
|
expect(result).toEqual(Uint8Array.from([0x22, 0x44, 0x88]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("must not allow multiple modules to provide cryptoSetup extension", async () => {
|
||||||
|
registerMockModuleWithCryptoSetupExtension();
|
||||||
|
const t = () => registerMockModuleWithCryptoSetupExtension();
|
||||||
|
expect(t).toThrow(Error);
|
||||||
|
expect(t).toThrow(
|
||||||
|
"adding cryptoSetup extension implementation from module MockModuleWithCryptoSetupExtension but an implementation was already provided",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("must not allow multiple modules to provide experimental extension", async () => {
|
||||||
|
registerMockModuleWithExperimentalExtension();
|
||||||
|
const t = () => registerMockModuleWithExperimentalExtension();
|
||||||
|
expect(t).toThrow(Error);
|
||||||
|
expect(t).toThrow(
|
||||||
|
"adding experimental extension implementation from module MockModuleWithExperimentalExtension but an implementation was already provided",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1877,10 +1877,10 @@
|
||||||
resolved "https://registry.yarnpkg.com/@matrix-org/olm/-/olm-3.2.15.tgz#55f3c1b70a21bbee3f9195cecd6846b1083451ec"
|
resolved "https://registry.yarnpkg.com/@matrix-org/olm/-/olm-3.2.15.tgz#55f3c1b70a21bbee3f9195cecd6846b1083451ec"
|
||||||
integrity sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==
|
integrity sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==
|
||||||
|
|
||||||
"@matrix-org/react-sdk-module-api@^2.3.0":
|
"@matrix-org/react-sdk-module-api@^2.4.0":
|
||||||
version "2.3.0"
|
version "2.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.3.0.tgz#85be5cfc73be0494c13d4dc9050cb70c58d7a08b"
|
resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.4.0.tgz#5e4552acbe728141f42c1d54d75dcb4efea9301c"
|
||||||
integrity sha512-x/ie44yaXNtE5AKcmQiW5yINVEIJ7IjjEc35vj6j52fM8tZ9XbJx9PANKSWsdd0NJp3OqyaeHftmN6ESfx4YoQ==
|
integrity sha512-cPb+YaqllfJkRX0ofcG/0YdHxCvcMAvUbdNMO2olpGL8vwbBP6mHdhbZ87z9pgsRIVOqfFuLUE3WeW0hxWrklQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.17.9"
|
"@babel/runtime" "^7.17.9"
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue