diff --git a/package.json b/package.json index d139ffb..04bb997 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "events": "^3.3.0", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call", "matrix-react-sdk": "github:matrix-org/matrix-react-sdk#robertlong/group-call", + "normalize.css": "^8.0.1", "postcss-preset-env": "^6.7.0", "re-resizable": "^6.9.0", "react": "^17.0.0", diff --git a/src/Header.jsx b/src/Header.jsx index 3eb83b7..4226746 100644 --- a/src/Header.jsx +++ b/src/Header.jsx @@ -31,10 +31,15 @@ export function LeftNav({ children, className, hideMobile, ...rest }) { ); } -export function RightNav({ children, className, ...rest }) { +export function RightNav({ children, className, hideMobile, ...rest }) { return (
{children} @@ -42,9 +47,9 @@ export function RightNav({ children, className, ...rest }) { ); } -export function HeaderLogo() { +export function HeaderLogo({ className }) { return ( - + ); diff --git a/src/Header.module.css b/src/Header.module.css index 3baba13..734d445 100644 --- a/src/Header.module.css +++ b/src/Header.module.css @@ -42,6 +42,10 @@ margin-right: 24px; } +.rightNav.hideMobile { + display: none; +} + .nav > :last-child { margin-right: 0; } @@ -103,7 +107,8 @@ @media (min-width: 800px) { .headerLogo, .roomAvatar, - .leftNav.hideMobile { + .leftNav.hideMobile, + .rightNav.hideMobile { display: flex; } diff --git a/src/Home.jsx b/src/Home.jsx index e19007a..d26878f 100644 --- a/src/Home.jsx +++ b/src/Home.jsx @@ -14,399 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useState, useRef, useEffect } from "react"; -import { useHistory, Link } from "react-router-dom"; -import { - useClient, - useGroupCallRooms, - usePublicRooms, - createRoom, - roomAliasFromRoomName, - useInteractiveRegistration, -} from "./ConferenceCallManagerHooks"; -import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; -import styles from "./Home.module.css"; -import { FieldRow, InputField, ErrorMessage } from "./Input"; -import { UserMenu } from "./UserMenu"; -import { Button } from "./button"; -import { CallList } from "./CallList"; -import classNames from "classnames"; +import React from "react"; +import { useClient } from "./ConferenceCallManagerHooks"; import { ErrorView, LoadingView } from "./FullScreenView"; -import { useModalTriggerState } from "./Modal"; -import { randomString } from "matrix-js-sdk/src/randomstring"; -import { JoinExistingCallModal } from "./JoinExistingCallModal"; -import { RecaptchaInput } from "./RecaptchaInput"; -import { UserMenuContainer } from "./UserMenuContainer"; +import { UnauthenticatedView } from "./home/UnauthenticatedView"; +import { RegisteredView } from "./home/RegisteredView"; export function Home() { - const { - isAuthenticated, - isGuest, - isPasswordlessUser, - loading, - error, - client, - } = useClient(); - - const [{ privacyPolicyUrl, recaptchaKey }, register] = - useInteractiveRegistration(); - const [recaptchaResponse, setRecaptchaResponse] = useState(); - - const history = useHistory(); - const [creatingRoom, setCreatingRoom] = useState(false); - const [createRoomError, setCreateRoomError] = useState(); - const { modalState, modalProps } = useModalTriggerState(); - const [existingRoomId, setExistingRoomId] = useState(); - - const onCreateRoom = useCallback( - (e) => { - e.preventDefault(); - const data = new FormData(e.target); - const roomName = data.get("roomName"); - const userName = data.get("userName"); - - async function onCreateRoom() { - let _client = client; - - if (!recaptchaResponse) { - return; - } - - if (!_client || isGuest) { - _client = await register( - userName, - randomString(16), - recaptchaResponse, - true - ); - } - - const roomIdOrAlias = await createRoom(_client, roomName); - - if (roomIdOrAlias) { - history.push(`/room/${roomIdOrAlias}`); - } - } - - setCreateRoomError(undefined); - setCreatingRoom(true); - - return onCreateRoom().catch((error) => { - if (error.errcode === "M_ROOM_IN_USE") { - setExistingRoomId(roomAliasFromRoomName(roomName)); - setCreateRoomError(undefined); - modalState.open(); - } else { - setCreateRoomError(error); - } - - setCreatingRoom(false); - }); - }, - [client, history, register, isGuest, recaptchaResponse] - ); - - const onJoinRoom = useCallback( - (e) => { - e.preventDefault(); - const data = new FormData(e.target); - const roomId = data.get("roomId"); - history.push(`/${roomId}`); - }, - [history] - ); - - const onJoinExistingRoom = useCallback(() => { - history.push(`/${existingRoomId}`); - }, [history, existingRoomId]); + const { isAuthenticated, isPasswordlessUser, loading, error, client } = + useClient(); if (loading) { return ; } else if (error) { return ; } else { - return ( - <> - {!isAuthenticated || isGuest ? ( - - ) : ( - - )} - {modalState.isOpen && ( - - )} - + return isAuthenticated ? ( + + ) : ( + ); } } - -function UnregisteredView({ - onCreateRoom, - createRoomError, - creatingRoom, - onJoinRoom, - privacyPolicyUrl, - recaptchaKey, - setRecaptchaResponse, -}) { - const acceptTermsRef = useRef(); - const [acceptTerms, setAcceptTerms] = useState(false); - - useEffect(() => { - if (!acceptTermsRef.current) { - return; - } - - if (!acceptTerms) { - acceptTermsRef.current.setCustomValidity( - "You must accept the terms to continue." - ); - } else { - acceptTermsRef.current.setCustomValidity(""); - } - }, [acceptTerms]); - - return ( -
-
- - - - - - -
-
-
-
-
-
-

Join a call

- - - - - - -
-
-
-

Create a call

- - - - - - - - setAcceptTerms(e.target.checked)} - checked={acceptTerms} - label="Accept Privacy Policy" - ref={acceptTermsRef} - /> - - Privacy Policy - - - {recaptchaKey && ( - - - - )} - {createRoomError && ( - - {createRoomError.message} - - )} - - - -
- -
-

- Not registered yet?{" "} - Create an account -

-
-
-
-
-
-
- ); -} - -function RegisteredView({ - client, - isPasswordlessUser, - isGuest, - onCreateRoom, - createRoomError, - creatingRoom, - onJoinRoom, -}) { - const publicRooms = usePublicRooms( - client, - import.meta.env.VITE_PUBLIC_SPACE_ROOM_ID - ); - const recentRooms = useGroupCallRooms(client); - - const hideCallList = publicRooms.length === 0 && recentRooms.length === 0; - - return ( -
-
- - - - - - -
-
-
-
-
-
-

Join a call

- - - - - - -
-
-
-

Create a call

- - - - {createRoomError && ( - - {createRoomError.message} - - )} - - - -
- {(isPasswordlessUser || isGuest) && ( -
-

- Not registered yet?{" "} - Create an account -

-
- )} -
-
-
- {!hideCallList && ( -
-
- {publicRooms.length > 0 && ( - - )} - {recentRooms.length > 0 && ( - - )} -
-
- )} -
-
- ); -} diff --git a/src/ProfileModal.jsx b/src/ProfileModal.jsx index 9dd15f9..6378f59 100644 --- a/src/ProfileModal.jsx +++ b/src/ProfileModal.jsx @@ -8,7 +8,6 @@ export function ProfileModal({ client, isAuthenticated, isPasswordlessUser, - isGuest, ...rest }) { const { onClose } = rest; @@ -66,7 +65,7 @@ export function ProfileModal({ onChange={onChangeDisplayName} /> - {isAuthenticated && !isGuest && !isPasswordlessUser && ( + {isAuthenticated && !isPasswordlessUser && ( { if (!loading && !isAuthenticated) { - setRegisteringGuest(true); - - registerGuest() - .then(() => { - setRegisteringGuest(false); - }) - .catch((error) => { - setRegistrationError(error); - setRegisteringGuest(false); - }); + setRegistrationError(new Error("Must be registered")); } }, [loading, isAuthenticated]); - if (loading || registeringGuest) { + if (loading) { return ; } @@ -95,16 +78,10 @@ export function Room() { return ; } - return ( - - ); + return ; } -export function GroupCall({ client, isGuest, isPasswordlessUser }) { +export function GroupCall({ client, isPasswordlessUser }) { const { roomId: maybeRoomId } = useParams(); const { hash, search } = useLocation(); const [simpleGrid, viaServers] = useMemo(() => { @@ -132,7 +109,6 @@ export function GroupCall({ client, isGuest, isPasswordlessUser }) { return ( { leave(); - if (!isGuest && !isPasswordlessUser) { + if (!isPasswordlessUser) { history.push("/"); } else { setLeft(true); } - }, [leave, history, isGuest]); + }, [leave, history]); if (error) { return ; @@ -215,7 +190,6 @@ export function GroupCallView({ - {!isGuest && } + {items.length === 0 ? ( diff --git a/src/UserMenuContainer.jsx b/src/UserMenuContainer.jsx index d9f2f5c..d70cb25 100644 --- a/src/UserMenuContainer.jsx +++ b/src/UserMenuContainer.jsx @@ -8,14 +8,8 @@ import { UserMenu } from "./UserMenu"; export function UserMenuContainer({ disableLogout }) { const location = useLocation(); const history = useHistory(); - const { - isAuthenticated, - isGuest, - isPasswordlessUser, - logout, - userName, - client, - } = useClient(); + const { isAuthenticated, isPasswordlessUser, logout, userName, client } = + useClient(); const { displayName, avatarUrl } = useProfile(client); const { modalState, modalProps } = useModalTriggerState(); @@ -52,7 +46,6 @@ export function UserMenuContainer({ disableLogout }) { diff --git a/src/form/Form.jsx b/src/form/Form.jsx new file mode 100644 index 0000000..c46064f --- /dev/null +++ b/src/form/Form.jsx @@ -0,0 +1,11 @@ +import classNames from "classnames"; +import React, { forwardRef } from "react"; +import styles from "./Form.module.css"; + +export const Form = forwardRef(({ children, className, ...rest }, ref) => { + return ( +
+ {children} +
+ ); +}); diff --git a/src/form/Form.module.css b/src/form/Form.module.css new file mode 100644 index 0000000..c1f7262 --- /dev/null +++ b/src/form/Form.module.css @@ -0,0 +1,4 @@ +.form { + display: flex; + flex-direction: column; +} diff --git a/src/home/RegisteredView.jsx b/src/home/RegisteredView.jsx new file mode 100644 index 0000000..eabec9b --- /dev/null +++ b/src/home/RegisteredView.jsx @@ -0,0 +1,131 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { + useGroupCallRooms, + usePublicRooms, +} from "../ConferenceCallManagerHooks"; +import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; +import styles from "../Home.module.css"; +import { FieldRow, InputField, ErrorMessage } from "../Input"; +import { Button } from "../button"; +import { CallList } from "../CallList"; +import classNames from "classnames"; +import { UserMenuContainer } from "../UserMenuContainer"; + +export function RegisteredView({ + client, + isPasswordlessUser, + onCreateRoom, + createRoomError, + creatingRoom, + onJoinRoom, +}) { + const publicRooms = usePublicRooms( + client, + import.meta.env.VITE_PUBLIC_SPACE_ROOM_ID + ); + const recentRooms = useGroupCallRooms(client); + + const hideCallList = publicRooms.length === 0 && recentRooms.length === 0; + + return ( +
+
+ + + + + + +
+
+
+
+
+
+

Join a call

+ + + + + + +
+
+
+

Create a call

+ + + + {createRoomError && ( + + {createRoomError.message} + + )} + + + +
+ {isPasswordlessUser && ( +
+

+ Not registered yet?{" "} + Create an account +

+
+ )} +
+
+
+ {!hideCallList && ( +
+
+ {publicRooms.length > 0 && ( + + )} + {recentRooms.length > 0 && ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx new file mode 100644 index 0000000..8c0b684 --- /dev/null +++ b/src/home/UnauthenticatedView.jsx @@ -0,0 +1,154 @@ +import React, { useCallback, useState } from "react"; +import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; +import { UserMenuContainer } from "../UserMenuContainer"; +import { useHistory } from "react-router-dom"; +import { FieldRow, InputField, ErrorMessage } from "../Input"; +import { Button } from "../button"; +import { randomString } from "matrix-js-sdk/src/randomstring"; +import { + createRoom, + useInteractiveRegistration, +} from "../ConferenceCallManagerHooks"; +import { useModalTriggerState } from "../Modal"; +import { JoinExistingCallModal } from "../JoinExistingCallModal"; +import { useRecaptcha } from "../useRecaptcha"; +import { Body, Caption, Title, Link, Headline } from "../typography/Typography"; +import { Form } from "../form/Form"; +import styles from "./UnauthenticatedView.module.css"; + +export function UnauthenticatedView() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [{ privacyPolicyUrl, recaptchaKey }, register] = + useInteractiveRegistration(); + const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); + const onSubmit = useCallback( + (e) => { + e.preventDefault(); + const data = new FormData(e.target); + const roomName = data.get("roomName"); + const userName = data.get("userName"); + + async function submit() { + setError(undefined); + setLoading(true); + const recaptchaResponse = await execute(); + const client = await register( + userName, + randomString(16), + recaptchaResponse, + true + ); + + const roomIdOrAlias = await createRoom(client, roomName); + + if (roomIdOrAlias) { + history.push(`/room/${roomIdOrAlias}`); + } + } + + submit().catch((error) => { + if (error.errcode === "M_ROOM_IN_USE") { + setExistingRoomId(roomAliasFromRoomName(roomName)); + setError(undefined); + modalState.open(); + } else { + console.error(error); + setLoading(false); + setError(error); + reset(); + } + }); + }, + [register] + ); + + const { modalState, modalProps } = useModalTriggerState(); + const [existingRoomId, setExistingRoomId] = useState(); + const history = useHistory(); + const onJoinExistingRoom = useCallback(() => { + history.push(`/${existingRoomId}`); + }, [history, existingRoomId]); + + return ( + <> +
+ + + + + + +
+
+
+ + Enter a call name +
+ + + + + + + + This site is protected by ReCAPTCHA and the Google{" "} + + Privacy Policy + {" "} + and{" "} + + Terms of Service + {" "} + apply. +
+ By clicking "Go", you agree to our{" "} + Terms and conditions + + {error && ( + + {error.message} + + )} + +
+ +
+
+ + + Login to your account + + + + Not registered yet?{" "} + + Create an account + + +
+
+ {modalState.isOpen && ( + + )} + + ); +} diff --git a/src/home/UnauthenticatedView.module.css b/src/home/UnauthenticatedView.module.css new file mode 100644 index 0000000..254ddb6 --- /dev/null +++ b/src/home/UnauthenticatedView.module.css @@ -0,0 +1,63 @@ +.container { + display: flex; + min-height: calc(100% - 64px); + flex-direction: column; + justify-content: space-between; +} + +.main { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.footer { + display: flex; + flex-direction: column; + align-items: center; + padding: 28px; +} + +.footer p { + margin-bottom: 0; +} + +.footer .mobileLoginLink { + display: flex; + margin-bottom: 24px; +} + +.logo { + display: flex; + margin-bottom: 54px; +} + +.headline { + margin-bottom: 40px; +} + +.form { + padding: 0 24px; + justify-content: center; + max-width: 360px; +} + +.form > * + * { + margin-bottom: 24px; +} + +@media (min-width: 800px) { + .logo { + display: none; + } + + .mobileLoginLink { + display: none; + } + + .container { + min-height: calc(100% - 76px); + } +} diff --git a/src/index.css b/src/index.css index a665b9c..53f1746 100644 --- a/src/index.css +++ b/src/index.css @@ -20,6 +20,8 @@ limitations under the License. Therefore we define a unicode-range to load which excludes the glyphs (to avoid having to maintain a fork of Inter). */ +@import "normalize.css/normalize.css"; + :root { --inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF; @@ -35,6 +37,7 @@ limitations under the License. --textColor4: #a9b2bc; --inputBorderColor: #394049; --inputBorderColorFocused: #0086e6; + --linkColor: #0086e6; } @font-face { @@ -139,6 +142,44 @@ body, flex-direction: column; } +h1, +h2, +h3, +h4, +h5, +h6, +p, +a { + margin-top: 0; +} + +/* Headline Semi Bold */ +h1 { + font-weight: 600; + font-size: 32px; + line-height: 39px; +} + +/* Title */ +h2 { + font-weight: 600; + font-size: 24px; + line-height: 29px; +} + +/* Subtitle */ +h3 { + font-weight: 400; + font-size: 18px; + line-height: 22px; +} + +/* Body */ +p { + font-size: 15px; + line-height: 24px; +} + a { color: var(--primaryColor); text-decoration: none; @@ -179,3 +220,7 @@ details[open] > summary { position: relative; height: 100%; } + +.grecaptcha-badge { + visibility: hidden; +} diff --git a/src/typography/Typography.jsx b/src/typography/Typography.jsx new file mode 100644 index 0000000..9193a48 --- /dev/null +++ b/src/typography/Typography.jsx @@ -0,0 +1,123 @@ +import React, { forwardRef } from "react"; +import classNames from "classnames"; +import { Link as RouterLink } from "react-router-dom"; +import styles from "./Typography.module.css"; + +export const Headline = forwardRef( + ({ as: Component = "h1", children, className, fontWeight, ...rest }, ref) => { + return ( + + {children} + + ); + } +); + +export const Title = forwardRef( + ({ as: Component = "h2", children, className, fontWeight, ...rest }, ref) => { + return ( + + {children} + + ); + } +); + +export const Subtitle = forwardRef( + ({ as: Component = "h3", children, className, fontWeight, ...rest }, ref) => { + return ( + + {children} + + ); + } +); + +export const Body = forwardRef( + ({ as: Component = "p", children, className, fontWeight, ...rest }, ref) => { + return ( + + {children} + + ); + } +); + +export const Caption = forwardRef( + ({ as: Component = "p", children, className, fontWeight, ...rest }, ref) => { + return ( + + {children} + + ); + } +); + +export const Micro = forwardRef( + ({ as: Component = "p", children, className, fontWeight, ...rest }, ref) => { + return ( + + {children} + + ); + } +); + +export const Link = forwardRef( + ( + { + as: Component = RouterLink, + children, + className, + color = "link", + href, + fontWeight, + ...rest + }, + ref + ) => { + let externalLinkProps; + + if (href) { + externalLinkProps = { + target: "_blank", + rel: "noreferrer noopener", + }; + } + + return ( + + {children} + + ); + } +); diff --git a/src/typography/Typography.module.css b/src/typography/Typography.module.css new file mode 100644 index 0000000..ca6e354 --- /dev/null +++ b/src/typography/Typography.module.css @@ -0,0 +1,29 @@ +.caption { + font-size: 12px; + line-height: 15px; +} + +.micro { + font-size: 10px; + line-height: 12px; +} + +.regular { + font-weight: 400; +} + +.semiBold { + font-weight: 600; +} + +.bold { + font-weight: 700; +} + +.link { + color: var(--linkColor); +} + +.primary { + color: var(--primaryColor); +} diff --git a/src/typography/Typography.stories.jsx b/src/typography/Typography.stories.jsx new file mode 100644 index 0000000..e31b9c3 --- /dev/null +++ b/src/typography/Typography.stories.jsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Headline, Title, Subtitle, Body, Caption, Micro } from "./Typography"; + +export default { + title: "Typography", + parameters: { + layout: "fullscreen", + }, +}; + +export const Typography = () => ( + <> + Headline Semi Bold + Title + Subtitle + Subtitle Semi Bold + Body + Body Semi Bold + Caption + Caption Semi Bold + Caption Bold + Micro + Micro bold + +); diff --git a/src/useRecaptcha.js b/src/useRecaptcha.js new file mode 100644 index 0000000..a743656 --- /dev/null +++ b/src/useRecaptcha.js @@ -0,0 +1,69 @@ +import { randomString } from "matrix-js-sdk/src/randomstring"; +import { useEffect, useCallback, useRef, useState } from "react"; + +const RECAPTCHA_SCRIPT_URL = + "https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit"; + +export function useRecaptcha(sitekey) { + const [recaptchaId] = useState(() => randomString(16)); + const resolvePromiseRef = useRef(); + + useEffect(() => { + if (!sitekey) { + return; + } + + const onRecaptchaLoaded = () => { + if (!document.getElementById(recaptchaId)) { + return; + } + + window.grecaptcha.render(recaptchaId, { + sitekey, + size: "invisible", + callback: (response) => { + if (resolvePromiseRef.current) { + resolvePromiseRef.current(response); + } + }, + }); + }; + + if ( + typeof window.grecaptcha !== "undefined" && + typeof window.grecaptcha.render === "function" + ) { + onRecaptchaLoaded(); + } else { + window.mxOnRecaptchaLoaded = onRecaptchaLoaded; + + if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) { + const scriptTag = document.createElement("script"); + scriptTag.src = RECAPTCHA_SCRIPT_URL; + scriptTag.async = true; + document.body.appendChild(scriptTag); + } + } + }, [recaptchaId, sitekey]); + + const execute = useCallback(() => { + if (!window.grecaptcha) { + return Promise.reject(new Error("Recaptcha not loaded")); + } + + return new Promise((resolve) => { + resolvePromiseRef.current = resolve; + window.grecaptcha.execute(); + }); + }, [recaptchaId]); + + const reset = useCallback(() => { + if (window.grecaptcha) { + window.grecaptcha.reset(); + } + }, [recaptchaId]); + + console.log(recaptchaId); + + return { execute, reset, recaptchaId }; +} diff --git a/yarn.lock b/yarn.lock index 82ede08..73e7c5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9155,6 +9155,11 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= +normalize.css@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3" + integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg== + npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"