${tex}`)
+ }
+ });
+ return phtml.html();
}
// ensure removal of escape backslashes in non-Markdown messages
if (md.indexOf("\\") > -1) {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 7fbcc1a350..282c1ce686 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -46,6 +46,13 @@
"Alternatively, you can try to use the public server at
turn.matrix.org
, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at
turn.matrix.org
, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.",
"Try using turn.matrix.org": "Try using turn.matrix.org",
"OK": "OK",
+ "Unable to access microphone": "Unable to access microphone",
+ "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.",
+ "Unable to access webcam / microphone": "Unable to access webcam / microphone",
+ "Call failed because no webcam or microphone could not be accessed. Check that:": "Call failed because no webcam or microphone could not be accessed. Check that:",
+ "A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
+ "Permission is granted to use the webcam": "Permission is granted to use the webcam",
+ "No other application is using the webcam": "No other application is using the webcam",
"Unable to capture screen": "Unable to capture screen",
"Existing Call": "Existing Call",
"You are already in a call.": "You are already in a call.",
@@ -755,6 +762,7 @@
"%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s",
"%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s",
"Change notification settings": "Change notification settings",
+ "Render LaTeX maths in messages": "Render LaTeX maths in messages",
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.",
"New spinner design": "New spinner design",
"Message Pinning": "Message Pinning",
@@ -954,9 +962,9 @@
"Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.",
"Export E2E room keys": "Export E2E room keys",
"Do you want to set an email address?": "Do you want to set an email address?",
- "Current password": "Current password",
- "New Password": "New Password",
"Confirm password": "Confirm password",
+ "Passwords don't match": "Passwords don't match",
+ "Current password": "Current password",
"Change Password": "Change Password",
"Your homeserver does not support cross-signing.": "Your homeserver does not support cross-signing.",
"Cross-signing is ready for use.": "Cross-signing is ready for use.",
@@ -2304,7 +2312,6 @@
"If you don't specify an email address, you won't be able to reset your password. Are you sure?": "If you don't specify an email address, you won't be able to reset your password. Are you sure?",
"Use an email address to recover your account": "Use an email address to recover your account",
"Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)",
- "Passwords don't match": "Passwords don't match",
"Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details",
"Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)",
"Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only",
@@ -2460,6 +2467,8 @@
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
"Failed to find the general chat for this community": "Failed to find the general chat for this community",
+ "Got an account?
Sign in": "Got an account?
Sign in",
+ "New here?
Create an account": "New here?
Create an account",
"Notification settings": "Notification settings",
"Security & privacy": "Security & privacy",
"All settings": "All settings",
@@ -2482,6 +2491,7 @@
"Your Matrix account on
": "Your Matrix account on
",
"No identity server is configured: add one in server settings to reset your password.": "No identity server is configured: add one in server settings to reset your password.",
"Sign in instead": "Sign in instead",
+ "New Password": "New Password",
"A verification email will be sent to your inbox to confirm setting your new password.": "A verification email will be sent to your inbox to confirm setting your new password.",
"Send Reset Email": "Send Reset Email",
"An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.",
diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts
index cc6fd29fe3..31e133be72 100644
--- a/src/settings/Settings.ts
+++ b/src/settings/Settings.ts
@@ -117,6 +117,12 @@ export interface ISetting {
}
export const SETTINGS: {[setting: string]: ISetting} = {
+ "feature_latex_maths": {
+ isFeature: true,
+ displayName: _td("Render LaTeX maths in messages"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
"feature_communities_v2_prototypes": {
isFeature: true,
displayName: _td(
diff --git a/src/stores/ModalWidgetStore.ts b/src/stores/ModalWidgetStore.ts
index 0485afd106..c0b64d76fe 100644
--- a/src/stores/ModalWidgetStore.ts
+++ b/src/stores/ModalWidgetStore.ts
@@ -64,7 +64,7 @@ export class ModalWidgetStore extends AsyncStoreWithClient
{
this.openSourceWidgetId = null;
this.modalInstance = null;
},
- });
+ }, null, /* priority = */ false, /* static = */ true);
};
public closeModalWidget = (sourceWidget: Widget, data?: IModalWidgetReturnData) => {
diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts
index 0f3138fe9e..b2fe630760 100644
--- a/src/stores/room-list/RoomListStore.ts
+++ b/src/stores/room-list/RoomListStore.ts
@@ -34,6 +34,7 @@ import { MarkedExecution } from "../../utils/MarkedExecution";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { NameFilterCondition } from "./filters/NameFilterCondition";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
+import { VisibilityProvider } from "./filters/VisibilityProvider";
interface IState {
tagsEnabled?: boolean;
@@ -401,6 +402,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient {
}
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise {
+ if (!VisibilityProvider.instance.isRoomVisible(room)) {
+ return; // don't do anything on rooms that aren't visible
+ }
+
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
@@ -544,7 +549,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient {
public async regenerateAllLists({trigger = true}) {
console.warn("Regenerating all room lists");
- const rooms = this.matrixClient.getVisibleRooms();
+ const rooms = this.matrixClient.getVisibleRooms()
+ .filter(r => VisibilityProvider.instance.isRoomVisible(r));
const customTags = new Set();
if (this.state.tagsEnabled) {
for (const room of rooms) {
diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts
index 439141edb4..25059aabe7 100644
--- a/src/stores/room-list/algorithms/Algorithm.ts
+++ b/src/stores/room-list/algorithms/Algorithm.ts
@@ -34,6 +34,7 @@ import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } f
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
import SettingsStore from "../../../settings/SettingsStore";
+import { VisibilityProvider } from "../filters/VisibilityProvider";
/**
* Fired when the Algorithm has determined a list has been updated.
@@ -188,6 +189,10 @@ export class Algorithm extends EventEmitter {
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms.
+ if (val && !VisibilityProvider.instance.isRoomVisible(val)) {
+ val = null; // the room isn't visible - lie to the rest of this function
+ }
+
// Set the last sticky room to indicate that we're in a change. The code throughout the
// class can safely handle a null room, so this should be safe to do as a backup.
this._lastStickyRoom = this._stickyRoom || {};
diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts
new file mode 100644
index 0000000000..553dd33ce0
--- /dev/null
+++ b/src/stores/room-list/filters/VisibilityProvider.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 {Room} from "matrix-js-sdk/src/models/room";
+import { RoomListCustomisations } from "../../../customisations/RoomList";
+
+export class VisibilityProvider {
+ private static internalInstance: VisibilityProvider;
+
+ private constructor() {
+ }
+
+ public static get instance(): VisibilityProvider {
+ if (!VisibilityProvider.internalInstance) {
+ VisibilityProvider.internalInstance = new VisibilityProvider();
+ }
+ return VisibilityProvider.internalInstance;
+ }
+
+ public isRoomVisible(room: Room): boolean {
+ /* eslint-disable prefer-const */
+ let isVisible = true; // Returned at the end of this function
+ let forced = false; // When true, this function won't bother calling the customisation points
+ /* eslint-enable prefer-const */
+
+ // ------
+ // TODO: The `if` statements to control visibility of custom room types
+ // would go here. The remainder of this function assumes that the statements
+ // will be here.
+ //
+ // When removing this comment block, please remove the lint disable lines in the area.
+ // ------
+
+ const isVisibleFn = RoomListCustomisations.isRoomVisible;
+ if (!forced && isVisibleFn) {
+ isVisible = isVisibleFn(room);
+ }
+
+ return isVisible;
+ }
+}
diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts
index 3485e153e1..cc2934aec1 100644
--- a/src/stores/widgets/StopGapWidget.ts
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -68,7 +68,7 @@ interface IAppTileProps {
}
// TODO: Don't use this because it's wrong
-class ElementWidget extends Widget {
+export class ElementWidget extends Widget {
constructor(private rawDefinition: IWidget) {
super(rawDefinition);
}
@@ -246,7 +246,7 @@ export class StopGapWidget extends EventEmitter {
public start(iframe: HTMLIFrameElement) {
if (this.started) return;
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
- const driver = new StopGapWidgetDriver( allowedCapabilities, this.mockWidget, this.kind);
+ const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId);
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("ready", () => this.emit("ready"));
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
index 59cdbfe3e5..60988040d3 100644
--- a/src/stores/widgets/StopGapWidgetDriver.ts
+++ b/src/stores/widgets/StopGapWidgetDriver.ts
@@ -16,6 +16,7 @@
import {
Capability,
+ EventDirection,
IOpenIDCredentials,
IOpenIDUpdate,
ISendEventDetails,
@@ -24,19 +25,21 @@ import {
SimpleObservable,
Widget,
WidgetDriver,
+ WidgetEventCapability,
WidgetKind,
} from "matrix-widget-api";
import { iterableDiff, iterableUnion } from "../../utils/iterables";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import ActiveRoomObserver from "../../ActiveRoomObserver";
import Modal from "../../Modal";
-import WidgetUtils from "../../utils/WidgetUtils";
-import SettingsStore from "../../settings/SettingsStore";
import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetCapabilitiesPromptDialog, {
getRememberedCapabilitiesForWidget,
} from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog";
import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions";
+import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore";
+import { WidgetType } from "../../widgets/WidgetType";
+import { EventType } from "matrix-js-sdk/src/@types/event";
// TODO: Purge this from the universe
@@ -44,13 +47,27 @@ export class StopGapWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set;
// TODO: Refactor widgetKind into the Widget class
- constructor(allowedCapabilities: Capability[], private forWidget: Widget, private forWidgetKind: WidgetKind) {
+ constructor(
+ allowedCapabilities: Capability[],
+ private forWidget: Widget,
+ private forWidgetKind: WidgetKind,
+ private inRoomId?: string,
+ ) {
super();
// Always allow screenshots to be taken because it's a client-induced flow. The widget can't
// spew screenshots at us and can't request screenshots of us, so it's up to us to provide the
// button if the widget says it supports screenshots.
this.allowedCapabilities = new Set([...allowedCapabilities, MatrixCapabilities.Screenshots]);
+
+ // Grant the permissions that are specific to given widget types
+ if (WidgetType.JITSI.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Room) {
+ this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
+ } else if (WidgetType.STICKERPICKER.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Account) {
+ const stickerSendingCap = WidgetEventCapability.forRoomEvent(EventDirection.Send, EventType.Sticker).raw;
+ this.allowedCapabilities.add(MatrixCapabilities.StickerSending); // legacy as far as MSC2762 is concerned
+ this.allowedCapabilities.add(stickerSendingCap);
+ }
}
public async validateCapabilities(requested: Set): Promise> {
@@ -112,28 +129,27 @@ export class StopGapWidgetDriver extends WidgetDriver {
}
public async askOpenID(observer: SimpleObservable) {
- const isUserWidget = this.forWidgetKind !== WidgetKind.Room; // modal and account widgets are "user" widgets
- const rawUrl = this.forWidget.templateUrl;
- const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.forWidget.id, rawUrl, isUserWidget);
+ const oidcState = WidgetPermissionStore.instance.getOIDCState(
+ this.forWidget, this.forWidgetKind, this.inRoomId,
+ );
const getToken = (): Promise => {
return MatrixClientPeg.get().getOpenIdToken();
};
- const settings = SettingsStore.getValue("widgetOpenIDPermissions");
- if (settings?.deny?.includes(widgetSecurityKey)) {
+ if (oidcState === OIDCState.Denied) {
return observer.update({state: OpenIDRequestState.Blocked});
}
- if (settings?.allow?.includes(widgetSecurityKey)) {
+ if (oidcState === OIDCState.Allowed) {
return observer.update({state: OpenIDRequestState.Allowed, token: await getToken()});
}
observer.update({state: OpenIDRequestState.PendingUserConfirmation});
Modal.createTrackedDialog("OpenID widget permissions", '', WidgetOpenIDPermissionsDialog, {
- widgetUrl: rawUrl,
- widgetId: this.forWidget.id,
- isUserWidget: isUserWidget,
+ widget: this.forWidget,
+ widgetKind: this.forWidgetKind,
+ inRoomId: this.inRoomId,
onFinished: async (confirm) => {
if (!confirm) {
diff --git a/src/stores/widgets/WidgetPermissionStore.ts b/src/stores/widgets/WidgetPermissionStore.ts
new file mode 100644
index 0000000000..41e8bc6652
--- /dev/null
+++ b/src/stores/widgets/WidgetPermissionStore.ts
@@ -0,0 +1,88 @@
+/*
+ * 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 SettingsStore from "../../settings/SettingsStore";
+import { Widget, WidgetKind } from "matrix-widget-api";
+import { MatrixClientPeg } from "../../MatrixClientPeg";
+import { SettingLevel } from "../../settings/SettingLevel";
+
+export enum OIDCState {
+ Allowed, // user has set the remembered value as allowed
+ Denied, // user has set the remembered value as disallowed
+ Unknown, // user has not set a remembered value
+}
+
+export class WidgetPermissionStore {
+ private static internalInstance: WidgetPermissionStore;
+
+ private constructor() {
+ }
+
+ public static get instance(): WidgetPermissionStore {
+ if (!WidgetPermissionStore.internalInstance) {
+ WidgetPermissionStore.internalInstance = new WidgetPermissionStore();
+ }
+ return WidgetPermissionStore.internalInstance;
+ }
+
+ // TODO (all functions here): Merge widgetKind with the widget definition
+
+ private packSettingKey(widget: Widget, kind: WidgetKind, roomId?: string): string {
+ let location = roomId;
+ if (kind !== WidgetKind.Room) {
+ location = MatrixClientPeg.get().getUserId();
+ }
+ if (kind === WidgetKind.Modal) {
+ location = '*MODAL*-' + location; // to guarantee differentiation from whatever spawned it
+ }
+ if (!location) {
+ throw new Error("Failed to determine a location to check the widget's OIDC state with");
+ }
+
+ return encodeURIComponent(`${location}::${widget.templateUrl}`);
+ }
+
+ public getOIDCState(widget: Widget, kind: WidgetKind, roomId?: string): OIDCState {
+ const settingsKey = this.packSettingKey(widget, kind, roomId);
+ const settings = SettingsStore.getValue("widgetOpenIDPermissions");
+ if (settings?.deny?.includes(settingsKey)) {
+ return OIDCState.Denied;
+ }
+ if (settings?.allow?.includes(settingsKey)) {
+ return OIDCState.Allowed;
+ }
+ return OIDCState.Unknown;
+ }
+
+ public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string, newState: OIDCState) {
+ const settingsKey = this.packSettingKey(widget, kind, roomId);
+
+ const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
+ if (!currentValues.allow) currentValues.allow = [];
+ if (!currentValues.deny) currentValues.deny = [];
+
+ if (newState === OIDCState.Allowed) {
+ currentValues.allow.push(settingsKey);
+ } else if (newState === OIDCState.Denied) {
+ currentValues.deny.push(settingsKey);
+ } else {
+ currentValues.allow = currentValues.allow.filter(c => c !== settingsKey);
+ currentValues.deny = currentValues.deny.filter(c => c !== settingsKey);
+ }
+
+ SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
+ }
+}
diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts
index 526c2d5ce7..986c68342c 100644
--- a/src/utils/WidgetUtils.ts
+++ b/src/utils/WidgetUtils.ts
@@ -22,7 +22,6 @@ import SdkConfig from "../SdkConfig";
import dis from '../dispatcher/dispatcher';
import WidgetEchoStore from '../stores/WidgetEchoStore';
import SettingsStore from "../settings/SettingsStore";
-import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import {IntegrationManagers} from "../integrations/IntegrationManagers";
import {Room} from "matrix-js-sdk/src/models/room";
import {WidgetType} from "../widgets/WidgetType";
@@ -457,27 +456,6 @@ export default class WidgetUtils {
return capWhitelist;
}
- static getWidgetSecurityKey(widgetId: string, widgetUrl: string, isUserWidget: boolean): string {
- let widgetLocation = ActiveWidgetStore.getRoomId(widgetId);
-
- if (isUserWidget) {
- const userWidget = WidgetUtils.getUserWidgetsArray()
- .find((w) => w.id === widgetId && w.content && w.content.url === widgetUrl);
-
- if (!userWidget) {
- throw new Error("No matching user widget to form security key");
- }
-
- widgetLocation = userWidget.sender;
- }
-
- if (!widgetLocation) {
- throw new Error("Failed to locate where the widget resides");
- }
-
- return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
- }
-
static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string} = {}) {
// NB. we can't just encodeURIComponent all of these because the $ signs need to be there
const queryStringParts = [
diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js
index 07cd51edbd..bf55e9c430 100644
--- a/test/components/views/messages/TextualBody-test.js
+++ b/test/components/views/messages/TextualBody-test.js
@@ -36,6 +36,7 @@ describe("", () => {
MatrixClientPeg.matrixClient = {
getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined,
+ isGuest: () => false,
};
const ev = mkEvent({
@@ -59,6 +60,7 @@ describe("", () => {
MatrixClientPeg.matrixClient = {
getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined,
+ isGuest: () => false,
};
const ev = mkEvent({
@@ -83,6 +85,7 @@ describe("", () => {
MatrixClientPeg.matrixClient = {
getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined,
+ isGuest: () => false,
};
});
@@ -135,6 +138,7 @@ describe("", () => {
getHomeserverUrl: () => "https://my_server/",
on: () => undefined,
removeListener: () => undefined,
+ isGuest: () => false,
};
});
diff --git a/yarn.lock b/yarn.lock
index 966a70d373..c06494d319 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6206,6 +6206,13 @@ jsx-ast-utils@^2.4.1:
array-includes "^3.1.1"
object.assign "^4.1.0"
+katex@^0.12.0:
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9"
+ integrity sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg==
+ dependencies:
+ commander "^2.19.0"
+
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"