diff --git a/package.json b/package.json index 62948954bba50d1571bd8e5a961185e38085d173..26f8587075cc2a619a976e6e66d3a340d64e61d5 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@fontsource/open-sans": "^4.4.5", + "@hcaptcha/react-hcaptcha": "^0.3.6", "@preact/preset-vite": "^2.0.0", "@styled-icons/bootstrap": "^10.34.0", "@styled-icons/feather": "^10.34.0", @@ -36,6 +37,7 @@ "@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/parser": "^4.27.0", "dayjs": "^1.10.5", + "detect-browser": "^5.2.0", "eslint": "^7.28.0", "eslint-config-preact": "^1.1.4", "localforage": "^1.9.0", @@ -43,6 +45,7 @@ "prettier": "^2.3.1", "react-device-detect": "^1.17.0", "react-helmet": "^6.1.0", + "react-hook-form": "6.3.0", "react-overlapping-panels": "1.1.2-patch.0", "react-redux": "^7.2.4", "react-router-dom": "^5.2.0", diff --git a/src/app.tsx b/src/app.tsx index f0f1f387aafcd20a369b995c5f19ae921a5e2965..605c43e0ba54823cc15c5b6da2f8e559be90a0c9 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,13 +2,15 @@ import { CheckAuth } from "./context/revoltjs/CheckAuth"; import { Route, Switch } from "react-router-dom"; import Context from "./context"; +import { Login } from "./pages/login/Login"; + export function App() { return ( <Context> <Switch> <Route path="/login"> <CheckAuth> - <h1>login</h1> + <Login /> </CheckAuth> </Route> <Route path="/"> diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index 76fb20e1cd68758869b7c037b22d5d07d75f51e0..42365cb9746b745bdd28fdeef9ca66d5dd3f448a 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -1,6 +1,7 @@ import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; import { createGlobalStyle } from "styled-components"; import { Children } from "../types/Preact"; +import { createContext } from "preact"; import { Helmet } from "react-helmet"; export type Variables = @@ -111,6 +112,8 @@ const GlobalTheme = createGlobalStyle<{ theme: Theme }>` } `; +export const ThemeContext = createContext<Theme>({} as any); + interface Props { children: Children; } @@ -119,7 +122,7 @@ export default function Theme(props: Props) { const theme = PRESETS.dark; return ( - <> + <ThemeContext.Provider value={theme}> <Helmet> <meta name="theme-color" @@ -132,6 +135,6 @@ export default function Theme(props: Props) { </Helmet> <GlobalTheme theme={theme} /> {props.children} - </> + </ThemeContext.Provider> ); } diff --git a/src/context/revoltjs/error.ts b/src/context/revoltjs/error.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c44c77cc10e5c04eed657ae1cae61e4ab05722a --- /dev/null +++ b/src/context/revoltjs/error.ts @@ -0,0 +1,18 @@ +export function takeError( + error: any +): string { + const type = error?.response?.data?.type; + let id = type; + if (!type) { + if (error?.response?.status === 403) { + return "Unauthorized"; + } else if (error && (!!error.isAxiosError && !error.response)) { + return "NetworkError"; + } + + console.error(error); + return "UnknownError"; + } + + return id; +} diff --git a/src/pages/login/FormField.tsx b/src/pages/login/FormField.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eb7f498899a4e8598ec0c842fe26fa7ebfa2d7a7 --- /dev/null +++ b/src/pages/login/FormField.tsx @@ -0,0 +1,68 @@ +import Overline from '../../components/ui/Overline'; +import InputBox from '../../components/ui/InputBox'; +import { Text, Localizer } from 'preact-i18n'; + +interface Props { + type: "email" | "username" | "password" | "invite" | "current_password"; + showOverline?: boolean; + register: Function; + error?: string; + name?: string; +} + +export default function FormField({ + type, + register, + showOverline, + error, + name +}: Props) { + return ( + <> + {showOverline && ( + <Overline error={error}> + <Text id={`login.${type}`} /> + </Overline> + )} + <Localizer> + <InputBox + placeholder={(<Text id={`login.enter.${type}`} />) as any} + name={ + type === "current_password" ? "password" : name ?? type + } + type={ + type === "invite" || type === "username" + ? "text" + : type === "current_password" + ? "password" + : type + } + ref={register( + type === "password" || type === "current_password" + ? { + validate: (value: string) => + value.length === 0 + ? "RequiredField" + : value.length < 8 + ? "TooShort" + : value.length > 1024 + ? "TooLong" + : undefined + } + : type === "email" + ? { + required: "RequiredField", + pattern: { + value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, + message: "InvalidEmail" + } + } + : type === "username" + ? { required: "RequiredField" } + : { required: "RequiredField" } + )} + /> + </Localizer> + </> + ); +} diff --git a/src/pages/login/Login.module.scss b/src/pages/login/Login.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..3b982bbb3159c37ae6d97ac635a85eb5f73b1613 --- /dev/null +++ b/src/pages/login/Login.module.scss @@ -0,0 +1,123 @@ +.login { + display: flex; + flex-direction: row; + + svg { + margin: auto; + } + + > div { + flex: 1; + } + + .content { + display: flex; + flex-direction: column; + + justify-content: space-between; + + .attribution { + color: var(--tertiary-background); + font-size: 12px; + line-height: 12px; + margin: 8px; + + display: flex; + flex-direction: row; + justify-content: space-between; + } + + .modal { + display: flex; + flex-direction: row; + + justify-content: center; + } + } + + .bg { + background-size: cover !important; + } +} + +.form { + display: flex; + flex-direction: column; + font-size: 14px; + + img { + width: 260px; + margin: auto; + } + + a { + margin-top: 4px; + } + + form { + margin: 1em 0; + display: flex; + flex-direction: column; + + button { + margin-top: 24px; + } + } + + .create { + text-align: center; + color: var(--tertiary-foreground); + + a { + margin: 0 4px; + } + } +} + +.success { + display: flex; + align-items: center; + flex-direction: column; + + .note { + color: var(--tertiary-foreground); + } + + .mailProvider { + padding: 24px 0; + } + + * { + margin: 0; + } + + h1 { + font-weight: 400; + } + + h2 { + font-weight: 300; + } +} + +.footer { + margin-top: 12px; + text-align: center; + color: var(--tertiary6); + + a { + color: var(--tertiary-background) !important; + cursor: pointer; + margin: 0 2px; + + &:hover { + color: var(--tertiary-foreground) !important; + } + } +} + +@media only screen and (max-width: 768px) { + .bg { + display: none; + } +} diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6e463dd0fcbefce4fa42ea7fb0643e4ea674435f --- /dev/null +++ b/src/pages/login/Login.tsx @@ -0,0 +1,70 @@ +import { Text } from "preact-i18n"; +import { Helmet } from "react-helmet"; +import styles from "./Login.module.scss"; +import { useContext } from "preact/hooks"; +import { APP_VERSION } from "../../version"; +import { LIBRARY_VERSION } from "revolt.js"; +import { Route, Switch } from "react-router-dom"; +import { ThemeContext } from "../../context/Theme"; +import { RevoltClient } from "../../context/revoltjs/RevoltClient"; + +import background from "./background.jpg"; + +import { FormLogin } from "./forms/FormLogin"; +import { FormCreate } from "./forms/FormCreate"; +import { FormResend } from "./forms/FormResend"; +import { FormReset, FormSendReset } from "./forms/FormReset"; + +export const Login = () => { + const theme = useContext(ThemeContext); + + return ( + <div className={styles.login}> + <Helmet> + <meta name="theme-color" content={theme.background} /> + </Helmet> + <div className={styles.content}> + <div className={styles.attribution}> + <span> + API:{" "} + <code>{RevoltClient.configuration?.revolt ?? "???"}</code>{" "} + · revolt.js: <code>{LIBRARY_VERSION}</code>{" "} + · App: <code>{APP_VERSION}</code> + </span> + <span> + {/*<LocaleSelector />*/} + </span> + </div> + <div className={styles.modal}> + <Switch> + <Route path="/login/create"> + <FormCreate /> + </Route> + <Route path="/login/resend"> + <FormResend /> + </Route> + <Route path="/login/reset/:token"> + <FormReset /> + </Route> + <Route path="/login/reset"> + <FormSendReset /> + </Route> + <Route path="/"> + <FormLogin /> + </Route> + </Switch> + </div> + <div className={styles.attribution}> + <span> + <Text id="general.image_by" /> ‎@lorenzoherrera + ‏· unsplash.com + </span> + </div> + </div> + <div + className={styles.bg} + style={{ background: `url('${background}')` }} + /> + </div> + ); +}; diff --git a/src/pages/login/background.jpg b/src/pages/login/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..44254ed74f6a3db4b822b8d8123e363933cd5ef8 Binary files /dev/null and b/src/pages/login/background.jpg differ diff --git a/src/pages/login/forms/CaptchaBlock.tsx b/src/pages/login/forms/CaptchaBlock.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7030dbd5b18d1f2aff9634704aaed56e41a2d44a --- /dev/null +++ b/src/pages/login/forms/CaptchaBlock.tsx @@ -0,0 +1,36 @@ +import { Text } from "preact-i18n"; +import { useEffect } from "preact/hooks"; +import styles from "../Login.module.scss"; +import HCaptcha from "@hcaptcha/react-hcaptcha"; +import Preloader from "../../../components/ui/Preloader"; +import { RevoltClient } from "../../../context/revoltjs/RevoltClient"; + +export interface CaptchaProps { + onSuccess: (token?: string) => void; + onCancel: () => void; +} + +export function CaptchaBlock(props: CaptchaProps) { + useEffect(() => { + if (!RevoltClient.configuration?.features.captcha.enabled) { + props.onSuccess(); + } + }, []); + + if (!RevoltClient.configuration?.features.captcha.enabled) + return <Preloader />; + + return ( + <div> + <HCaptcha + sitekey={RevoltClient.configuration.features.captcha.key} + onVerify={token => props.onSuccess(token)} + /> + <div className={styles.footer}> + <a onClick={props.onCancel}> + <Text id="login.cancel" /> + </a> + </div> + </div> + ); +} diff --git a/src/pages/login/forms/Form.tsx b/src/pages/login/forms/Form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff28139c544397d031b84db7de6f0fcb8aa844c7 --- /dev/null +++ b/src/pages/login/forms/Form.tsx @@ -0,0 +1,236 @@ +import { Legal } from "./Legal"; +import { Text } from "preact-i18n"; +import { Link } from "react-router-dom"; +import { useState } from "preact/hooks"; +import styles from "../Login.module.scss"; +import { useForm } from "react-hook-form"; +import { MailProvider } from "./MailProvider"; +import { CheckCircle, Mail } from "@styled-icons/feather"; +import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock"; +import { takeError } from "../../../context/revoltjs/error"; +import { RevoltClient } from "../../../context/revoltjs/RevoltClient"; + +import FormField from "../FormField"; +import Button from "../../../components/ui/Button"; +import Overline from "../../../components/ui/Overline"; +import Preloader from "../../../components/ui/Preloader"; + +interface Props { + page: "create" | "login" | "send_reset" | "reset" | "resend"; + callback: (fields: { + email: string; + password: string; + invite: string; + captcha?: string; + }) => Promise<void>; +} + +function getInviteCode() { + if (typeof window === 'undefined') return ''; + + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + return code ?? ''; +} + +export function Form({ page, callback }: Props) { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState<string | undefined>(undefined); + const [error, setGlobalError] = useState<string | undefined>(undefined); + const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined); + + const { handleSubmit, register, errors, setError } = useForm({ + defaultValues: { + email: '', + password: '', + invite: getInviteCode() + } + }); + + async function onSubmit(data: { + email: string; + password: string; + invite: string; + }) { + setGlobalError(undefined); + setLoading(true); + + function onError(err: any) { + setLoading(false); + + const error = takeError(err); + switch (error) { + case "email_in_use": + return setError("email", { type: "", message: error }); + case "unknown_user": + return setError("email", { type: "", message: error }); + case "invalid_invite": + return setError("invite", { type: "", message: error }); + } + + setGlobalError(error); + } + + try { + if ( + RevoltClient.configuration?.features.captcha.enabled && + page !== "reset" + ) { + setCaptcha({ + onSuccess: async captcha => { + setCaptcha(undefined); + try { + await callback({ ...data, captcha }); + setSuccess(data.email); + } catch (err) { + onError(err); + } + }, + onCancel: () => { + setCaptcha(undefined); + setLoading(false); + } + }); + } else { + await callback(data); + setSuccess(data.email); + } + } catch (err) { + onError(err); + } + } + + if (typeof success !== "undefined") { + return ( + <div className={styles.success}> + {RevoltClient.configuration?.features.email ? ( + <> + <Mail size={72} /> + <h2> + <Text id="login.check_mail" /> + </h2> + <p className={styles.note}> + <Text id="login.email_delay" /> + </p> + <MailProvider email={success} /> + </> + ) : ( + <> + <CheckCircle size={72} /> + <h1> + <Text id="login.successful_registration" /> + </h1> + </> + )} + <span className={styles.footer}> + <Link to="/login"> + <a> + <Text id="login.remembered" /> + </a> + </Link> + </span> + </div> + ); + } + + if (captcha) return <CaptchaBlock {...captcha} />; + if (loading) return <Preloader />; + + return ( + <div className={styles.form}> + <form onSubmit={handleSubmit(onSubmit) as any}> + {page !== "reset" && ( + <FormField + type="email" + register={register} + showOverline + error={errors.email?.message} + /> + )} + {(page === "login" || + page === "create" || + page === "reset") && ( + <FormField + type="password" + register={register} + showOverline + error={errors.password?.message} + /> + )} + {RevoltClient.configuration?.features.invite_only && + page === "create" && ( + <FormField + type="invite" + register={register} + showOverline + error={errors.invite?.message} + /> + )} + {error && ( + <Overline type="error" error={error}> + <Text id={`login.error.${page}`} /> + </Overline> + )} + <Button> + <Text + id={ + page === "create" + ? "login.register" + : page === "login" + ? "login.title" + : page === "reset" + ? "login.set_password" + : page === "resend" + ? "login.resend" + : "login.reset" + } + /> + </Button> + </form> + {page === "create" && ( + <> + <span className={styles.create}> + <Text id="login.existing" /> + <Link to="/login"> + <Text id="login.title" /> + </Link> + </span> + <span className={styles.create}> + <Text id="login.missing_verification" /> + <Link to="/login/resend"> + <Text id="login.resend" /> + </Link> + </span> + </> + )} + {page === "login" && ( + <> + <span className={styles.create}> + <Text id="login.new" /> + <Link to="/login/create"> + <Text id="login.create" /> + </Link> + </span> + <span className={styles.create}> + <Text id="login.forgot" /> + <Link to="/login/reset"> + <Text id="login.reset" /> + </Link> + </span> + </> + )} + {(page === "reset" || + page === "resend" || + page === "send_reset") && ( + <> + <span className={styles.create}> + <Link to="/login"> + <Text id="login.remembered" /> + </Link> + </span> + </> + )} + <Legal /> + </div> + ); +} diff --git a/src/pages/login/forms/FormCreate.tsx b/src/pages/login/forms/FormCreate.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d586c5c390150915fdd64e395aa16981e10ab8d --- /dev/null +++ b/src/pages/login/forms/FormCreate.tsx @@ -0,0 +1,13 @@ +import { RevoltClient } from "../../../context/revoltjs/RevoltClient"; +import { Form } from "./Form"; + +export function FormCreate() { + return ( + <Form + page="create" + callback={async data => { + await RevoltClient.register(process.env.API_SERVER as string, data); + }} + /> + ); +} diff --git a/src/pages/login/forms/FormLogin.tsx b/src/pages/login/forms/FormLogin.tsx new file mode 100644 index 0000000000000000000000000000000000000000..04e6f386e7219ecb40f3d4e63dc9cdccf7fe1f88 --- /dev/null +++ b/src/pages/login/forms/FormLogin.tsx @@ -0,0 +1,29 @@ +import { Form } from "./Form"; +import { useContext } from "preact/hooks"; +import { useHistory } from "react-router-dom"; +import { deviceDetect } from "react-device-detect"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +export function FormLogin() { + const { operations } = useContext(AppContext); + const history = useHistory(); + + return ( + <Form + page="login" + callback={async data => { + const browser = deviceDetect(); + let device_name; + if (browser) { + const { name, os } = browser; + device_name = `${name} on ${os}`; + } else { + device_name = "Unknown Device"; + } + + await operations.login({ ...data, device_name }); + history.push("/"); + }} + /> + ); +} diff --git a/src/pages/login/forms/FormResend.tsx b/src/pages/login/forms/FormResend.tsx new file mode 100644 index 0000000000000000000000000000000000000000..badbabf30bfa37a2a45bc511b1acab79996bedc7 --- /dev/null +++ b/src/pages/login/forms/FormResend.tsx @@ -0,0 +1,13 @@ +import { RevoltClient } from "../../../context/revoltjs/RevoltClient"; +import { Form } from "./Form"; + +export function FormResend() { + return ( + <Form + page="resend" + callback={async data => { + await RevoltClient.req("POST", "/auth/resend", data); + }} + /> + ); +} diff --git a/src/pages/login/forms/FormReset.tsx b/src/pages/login/forms/FormReset.tsx new file mode 100644 index 0000000000000000000000000000000000000000..01ddee7676e452518e8f9d24aa99228498a01cf4 --- /dev/null +++ b/src/pages/login/forms/FormReset.tsx @@ -0,0 +1,32 @@ +import { Form } from "./Form"; +import { useHistory, useParams } from "react-router-dom"; +import { RevoltClient } from "../../../context/revoltjs/RevoltClient"; + +export function FormSendReset() { + return ( + <Form + page="send_reset" + callback={async data => { + await RevoltClient.req("POST", "/auth/send_reset", data); + }} + /> + ); +} + +export function FormReset() { + const { token } = useParams<{ token: string }>(); + const history = useHistory(); + + return ( + <Form + page="reset" + callback={async data => { + await RevoltClient.req("POST", "/auth/reset" as any, { + token, + ...(data as any) + }); + history.push("/login"); + }} + /> + ); +} diff --git a/src/pages/login/forms/Legal.tsx b/src/pages/login/forms/Legal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2517352c1d8bec309364e78af102d63f6cb362f5 --- /dev/null +++ b/src/pages/login/forms/Legal.tsx @@ -0,0 +1,29 @@ +import styles from "../Login.module.scss"; +import { Text } from "preact-i18n"; + +export function Legal() { + return ( + <span className={styles.footer}> + <a + href="https://revolt.chat/about" + target="_blank" + > + <Text id="general.about" /> + </a> + · + <a + href="https://revolt.chat/terms" + target="_blank" + > + <Text id="general.tos" /> + </a> + · + <a + href="https://revolt.chat/privacy" + target="_blank" + > + <Text id="general.privacy" /> + </a> + </span> + ); +} diff --git a/src/pages/login/forms/MailProvider.tsx b/src/pages/login/forms/MailProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..261857ba1dc545fc7ddfeea0e681af4c6f76d6f0 --- /dev/null +++ b/src/pages/login/forms/MailProvider.tsx @@ -0,0 +1,55 @@ +import { Text } from "preact-i18n"; +import styles from "../Login.module.scss"; +import Button from "../../../components/ui/Button"; + +interface Props { + email?: string; +} + +function mapMailProvider(email?: string): [string, string] | undefined { + if (!email) return; + + const match = /@(.+)/.exec(email); + if (match === null) return; + + const domain = match[1]; + switch (domain) { + case "gmail.com": + return ["Gmail", "https://gmail.com"]; + case "tuta.io": + return ["Tutanota", "https://mail.tutanota.com"]; + case "outlook.com": + return ["Outlook", "https://outlook.live.com"]; + case "yahoo.com": + return ["Yahoo", "https://mail.yahoo.com"]; + case "wp.pl": + return ["WP Poczta", "https://poczta.wp.pl"]; + case "protonmail.com": + case "protonmail.ch": + return ["ProtonMail", "https://mail.protonmail.com"]; + case "seznam.cz": + case "email.cz": + case "post.cz": + return ["Seznam", "https://email.seznam.cz"]; + default: + return [domain, `https://${domain}`]; + } +} + +export function MailProvider({ email }: Props) { + const provider = mapMailProvider(email); + if (!provider) return null; + + return ( + <div className={styles.mailProvider}> + <a href={provider[1]} target="_blank"> + <Button> + <Text + id="login.open_mail_provider" + fields={{ provider: provider[0] }} + /> + </Button> + </a> + </div> + ); +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f4ec86e7cdc28b6487e85020c67ce37fc31ed5f --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const APP_VERSION = "0.1.9-alpha.7"; diff --git a/yarn.lock b/yarn.lock index 52f249918116840889f59f44288f4dfa4518983a..5f17267e42ffcfefc293b3375a840a48c7d85ade 100644 --- a/yarn.lock +++ b/yarn.lock @@ -955,6 +955,11 @@ dependencies: "@hapi/hoek" "^8.3.0" +"@hcaptcha/react-hcaptcha@^0.3.6": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@hcaptcha/react-hcaptcha/-/react-hcaptcha-0.3.6.tgz#cbbb9abdaea451a4df408bc9d476e8b17f0b63f4" + integrity sha512-DQ5nvGVbbhd2IednxRhCV9wiPcCmclEV7bH98yGynGCXzO5XftO/XC0a1M1kEf9Ee+CLO/u+1HM/uE/PSrC3vQ== + "@insertish/mutable@1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@insertish/mutable/-/mutable-1.0.6.tgz#f42eaba8528ff68cc8065d51f9bbbd30a24f34de" @@ -1752,6 +1757,11 @@ define-properties@^1.1.3: dependencies: object-keys "^1.0.12" +detect-browser@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/detect-browser/-/detect-browser-5.2.0.tgz#c9cd5afa96a6a19fda0bbe9e9be48a6b6e1e9c97" + integrity sha512-tr7XntDAu50BVENgQfajMLzacmSe34D+qZc4zjnniz0ZVuw/TZcLcyxHQjYpJTM36sGEkZZlYLnIM1hH7alTMA== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -3061,6 +3071,11 @@ react-helmet@^6.1.0: react-fast-compare "^3.1.1" react-side-effect "^2.1.0" +react-hook-form@6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.3.0.tgz#5c1926d51d4532f44818ef73f96d1a8c11015a76" + integrity sha512-Xz7xxnILftxttc6H+miTSi2eYPehiW3XdsPaqY5dW8HcURFZPrnpxnmaRqz6JtZcbfRM8qjjppP/pOBaUzhn4w== + react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"