diff --git a/.env.example b/.env.example index 2f62d61..7359d2c 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,4 @@ # VITE_THEME_QUINARY_CONTENT=#394049 # VITE_THEME_SYSTEM=#21262c # VITE_THEME_BACKGROUND=#15191e +# VITE_THEME_BACKGROUND_85=#15191ed9 diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 7b5995f..97c5eff 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -24,7 +24,7 @@ declare global { // TypeScript doesn't know about the experimental setSinkId method, so we // declare it ourselves - interface MediaElement extends HTMLMediaElement { + interface MediaElement extends HTMLVideoElement { setSinkId: (id: string) => void; } } diff --git a/src/App.jsx b/src/App.tsx similarity index 96% rename from src/App.jsx rename to src/App.tsx index 1782f69..b41271b 100644 --- a/src/App.jsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import React from "react"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { OverlayProvider } from "@react-aria/overlays"; + import { HomePage } from "./home/HomePage"; import { LoginPage } from "./auth/LoginPage"; import { RegisterPage } from "./auth/RegisterPage"; @@ -31,7 +32,11 @@ import { CrashView } from "./FullScreenView"; const SentryRoute = Sentry.withSentryRouting(Route); -export default function App({ history }) { +interface AppProps { + history: History; +} + +export default function App({ history }: AppProps) { usePageFocusStyle(); const errorPage = ; diff --git a/src/Avatar.tsx b/src/Avatar.tsx index 9a9bae8..a4aa826 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -48,11 +48,11 @@ const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) => interface Props extends React.HTMLAttributes { bgKey?: string; - src: string; - fallback: string; + src?: string; size?: Size | number; - className: string; + className?: string; style?: CSSProperties; + fallback: string; } export const Avatar: React.FC = ({ diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 7960209..08eb91e 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -67,6 +67,7 @@ interface ClientState { changePassword: (password: string) => Promise; logout: () => void; setClient: (client: MatrixClient, session: Session) => void; + error?: Error; } const ClientContext = createContext(null); @@ -151,6 +152,7 @@ export const ClientProvider: FC = ({ children }) => { isAuthenticated: Boolean(client), isPasswordlessUser, userName: client?.getUserIdLocalpart(), + error: undefined, }); }) .catch((err) => { @@ -161,6 +163,7 @@ export const ClientProvider: FC = ({ children }) => { isAuthenticated: false, isPasswordlessUser: false, userName: null, + error: undefined, }); }); }, []); @@ -190,6 +193,7 @@ export const ClientProvider: FC = ({ children }) => { isAuthenticated: true, isPasswordlessUser: false, userName: client.getUserIdLocalpart(), + error: undefined, }); }, [client] @@ -210,6 +214,7 @@ export const ClientProvider: FC = ({ children }) => { isAuthenticated: true, isPasswordlessUser: session.passwordlessUser, userName: newClient.getUserIdLocalpart(), + error: undefined, }); } else { clearSession(); @@ -220,6 +225,7 @@ export const ClientProvider: FC = ({ children }) => { isAuthenticated: false, isPasswordlessUser: false, userName: null, + error: undefined, }); } }, @@ -278,6 +284,7 @@ export const ClientProvider: FC = ({ children }) => { logout, userName, setClient, + error: undefined, }), [ loading, diff --git a/src/Facepile.jsx b/src/Facepile.tsx similarity index 54% rename from src/Facepile.jsx rename to src/Facepile.tsx index e19e642..2469892 100644 --- a/src/Facepile.jsx +++ b/src/Facepile.tsx @@ -1,22 +1,48 @@ -import React from "react"; -import styles from "./Facepile.module.css"; -import classNames from "classnames"; -import { Avatar, sizes } from "./Avatar"; +/* +Copyright 2022 New Vector Ltd -const overlapMap = { - xs: 2, - sm: 4, - md: 8, +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { HTMLAttributes } from "react"; +import classNames from "classnames"; +import { MatrixClient, RoomMember } from "matrix-js-sdk"; + +import styles from "./Facepile.module.css"; +import { Avatar, Size, sizes } from "./Avatar"; + +const overlapMap: Partial> = { + [Size.XS]: 2, + [Size.SM]: 4, + [Size.MD]: 8, }; +interface Props extends HTMLAttributes { + className: string; + client: MatrixClient; + participants: RoomMember[]; + max?: number; + size?: Size; +} + export function Facepile({ className, client, participants, - max, - size, + max = 3, + size = Size.XS, ...rest -}) { +}: Props) { const _size = sizes.get(size); const _overlap = overlapMap[size]; @@ -56,8 +82,3 @@ export function Facepile({ ); } - -Facepile.defaultProps = { - max: 3, - size: "xs", -}; diff --git a/src/FullScreenView.jsx b/src/FullScreenView.tsx similarity index 87% rename from src/FullScreenView.jsx rename to src/FullScreenView.tsx index 6dd7127..9182111 100644 --- a/src/FullScreenView.jsx +++ b/src/FullScreenView.tsx @@ -1,13 +1,19 @@ -import React, { useCallback, useEffect } from "react"; +import React, { ReactNode, useCallback, useEffect } from "react"; import { useLocation } from "react-router-dom"; -import styles from "./FullScreenView.module.css"; -import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import classNames from "classnames"; + +import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import { LinkButton, Button } from "./button"; import { useSubmitRageshake } from "./settings/submit-rageshake"; import { ErrorMessage } from "./input/Input"; +import styles from "./FullScreenView.module.css"; -export function FullScreenView({ className, children }) { +interface FullScreenViewProps { + className?: string; + children: ReactNode; +} + +export function FullScreenView({ className, children }: FullScreenViewProps) { return (
@@ -23,7 +29,11 @@ export function FullScreenView({ className, children }) { ); } -export function ErrorView({ error }) { +interface ErrorViewProps { + error: Error; +} + +export function ErrorView({ error }: ErrorViewProps) { const location = useLocation(); useEffect(() => { @@ -31,7 +41,7 @@ export function ErrorView({ error }) { }, [error]); const onReload = useCallback(() => { - window.location = "/"; + window.location.href = "/"; }, []); return ( @@ -72,7 +82,7 @@ export function CrashView() { }, [submitRageshake]); const onReload = useCallback(() => { - window.location = "/"; + window.location.href = "/"; }, []); let logsComponent; diff --git a/src/Header.jsx b/src/Header.tsx similarity index 63% rename from src/Header.jsx rename to src/Header.tsx index c1cf771..0471526 100644 --- a/src/Header.jsx +++ b/src/Header.tsx @@ -1,18 +1,26 @@ import classNames from "classnames"; -import React, { useCallback, useRef } from "react"; +import React, { HTMLAttributes, ReactNode, useCallback, useRef } from "react"; import { Link } from "react-router-dom"; -import styles from "./Header.module.css"; -import { ReactComponent as Logo } from "./icons/Logo.svg"; -import { ReactComponent as VideoIcon } from "./icons/Video.svg"; -import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg"; import { useButton } from "@react-aria/button"; -import { Subtitle } from "./typography/Typography"; -import { Avatar } from "./Avatar"; -import { IncompatibleVersionModal } from "./IncompatibleVersionModal"; +import { AriaButtonProps } from "@react-types/button"; +import { Room } from "matrix-js-sdk"; + +import styles from "./Header.module.css"; import { useModalTriggerState } from "./Modal"; import { Button } from "./button"; +import { ReactComponent as Logo } from "./icons/Logo.svg"; +import { ReactComponent as VideoIcon } from "./icons/Video.svg"; +import { Subtitle } from "./typography/Typography"; +import { Avatar, Size } from "./Avatar"; +import { IncompatibleVersionModal } from "./IncompatibleVersionModal"; +import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg"; -export function Header({ children, className, ...rest }) { +interface HeaderProps extends HTMLAttributes { + children: ReactNode; + className?: string; +} + +export function Header({ children, className, ...rest }: HeaderProps) { return (
{children} @@ -20,7 +28,18 @@ export function Header({ children, className, ...rest }) { ); } -export function LeftNav({ children, className, hideMobile, ...rest }) { +interface LeftNavProps extends HTMLAttributes { + children: ReactNode; + className?: string; + hideMobile?: boolean; +} + +export function LeftNav({ + children, + className, + hideMobile, + ...rest +}: LeftNavProps) { return (
{ + children?: ReactNode; + className?: string; + hideMobile?: string; +} + +export function RightNav({ + children, + className, + hideMobile, + ...rest +}: RightNavProps) { return (
@@ -60,12 +94,17 @@ export function HeaderLogo({ className }) { ); } -export function RoomHeaderInfo({ roomName, avatarUrl }) { +interface RoomHeaderInfo { + roomName: string; + avatarUrl: string; +} + +export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) { return ( <>
{ + roomName: string; + avatarUrl: string; + isEmbedded: boolean; +} + export function RoomSetupHeaderInfo({ roomName, avatarUrl, isEmbedded, ...rest -}) { +}: RoomSetupHeaderInfoProps) { const ref = useRef(); const { buttonProps } = useButton(rest, ref); @@ -102,7 +147,15 @@ export function RoomSetupHeaderInfo({ ); } -export function VersionMismatchWarning({ users, room }) { +interface VersionMismatchWarningProps { + users: Set; + room: Room; +} + +export function VersionMismatchWarning({ + users, + room, +}: VersionMismatchWarningProps) { const { modalState, modalProps } = useModalTriggerState(); const onDetailsClick = useCallback(() => { diff --git a/src/IndexedDBWorker.js b/src/IndexedDBWorker.js deleted file mode 100644 index 0e373ca..0000000 --- a/src/IndexedDBWorker.js +++ /dev/null @@ -1,5 +0,0 @@ -import { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker"; - -const remoteWorker = new IndexedDBStoreWorker(self.postMessage); - -self.onmessage = remoteWorker.onMessage; diff --git a/src/IndexedDBWorker.ts b/src/IndexedDBWorker.ts new file mode 100644 index 0000000..a9ddecd --- /dev/null +++ b/src/IndexedDBWorker.ts @@ -0,0 +1,6 @@ +import { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const remoteWorker = new IndexedDBStoreWorker((self as any).postMessage); + +self.onmessage = remoteWorker.onMessage; diff --git a/src/ListBox.jsx b/src/ListBox.jsx deleted file mode 100644 index 478b6f0..0000000 --- a/src/ListBox.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useRef } from "react"; -import { useListBox, useOption } from "@react-aria/listbox"; -import styles from "./ListBox.module.css"; -import classNames from "classnames"; - -export function ListBox(props) { - const ref = useRef(); - let { listBoxRef = ref, state } = props; - const { listBoxProps } = useListBox(props, state, listBoxRef); - - return ( -
    - {[...state.collection].map((item) => ( -
- ); -} - -function Option({ item, state, className }) { - const ref = useRef(); - const { optionProps, isSelected, isFocused, isDisabled } = useOption( - { key: item.key }, - state, - ref - ); - - return ( -
  • - {item.rendered} -
  • - ); -} diff --git a/src/ListBox.tsx b/src/ListBox.tsx new file mode 100644 index 0000000..a57d440 --- /dev/null +++ b/src/ListBox.tsx @@ -0,0 +1,89 @@ +/* +Copyright 2022 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useRef } from "react"; +import { useListBox, useOption, AriaListBoxOptions } from "@react-aria/listbox"; +import { ListState } from "@react-stately/list"; +import { Node } from "@react-types/shared"; +import classNames from "classnames"; + +import styles from "./ListBox.module.css"; + +interface ListBoxProps extends AriaListBoxOptions { + className: string; + optionClassName: string; + listBoxRef: React.MutableRefObject; + state: ListState; +} + +export function ListBox({ + state, + optionClassName, + className, + listBoxRef, + ...rest +}: ListBoxProps) { + const ref = useRef(); + if (!listBoxRef) listBoxRef = ref; + + const { listBoxProps } = useListBox(rest, state, listBoxRef); + + return ( +
      + {[...state.collection].map((item) => ( +
    + ); +} + +interface OptionProps { + className: string; + state: ListState; + item: Node; +} + +function Option({ item, state, className }: OptionProps) { + const ref = useRef(); + const { optionProps, isSelected, isFocused, isDisabled } = useOption( + { key: item.key }, + state, + ref + ); + + return ( +
  • + {item.rendered} +
  • + ); +} diff --git a/src/Menu.jsx b/src/Menu.tsx similarity index 54% rename from src/Menu.jsx rename to src/Menu.tsx index 260db87..82159bd 100644 --- a/src/Menu.jsx +++ b/src/Menu.tsx @@ -1,15 +1,30 @@ -import React, { useRef, useState } from "react"; -import styles from "./Menu.module.css"; -import { useMenu, useMenuItem } from "@react-aria/menu"; -import { useTreeState } from "@react-stately/tree"; +import React, { Key, useRef, useState } from "react"; +import { AriaMenuOptions, useMenu, useMenuItem } from "@react-aria/menu"; +import { TreeState, useTreeState } from "@react-stately/tree"; import { mergeProps } from "@react-aria/utils"; import { useFocus } from "@react-aria/interactions"; import classNames from "classnames"; +import { Node } from "@react-types/shared"; -export function Menu({ className, onAction, ...rest }) { - const state = useTreeState({ ...rest, selectionMode: "none" }); +import styles from "./Menu.module.css"; + +interface MenuProps extends AriaMenuOptions { + className?: String; + onClose?: () => void; + onAction: (value: Key) => void; + label?: string; +} + +export function Menu({ + className, + onAction, + onClose, + label, + ...rest +}: MenuProps) { + const state = useTreeState({ ...rest, selectionMode: "none" }); const menuRef = useRef(); - const { menuProps } = useMenu(rest, state, menuRef); + const { menuProps } = useMenu(rest, state, menuRef); return (
      ))}
    ); } -function MenuItem({ item, state, onAction, onClose }) { +interface MenuItemProps { + item: Node; + state: TreeState; + onAction: (value: Key) => void; + onClose: () => void; +} + +function MenuItem({ item, state, onAction, onClose }: MenuItemProps) { const ref = useRef(); const { menuItemProps } = useMenuItem( { key: item.key, - isDisabled: item.isDisabled, onAction, onClose, }, diff --git a/src/Modal.module.css b/src/Modal.module.css index bf654d2..96bda6f 100644 --- a/src/Modal.module.css +++ b/src/Modal.module.css @@ -28,6 +28,7 @@ } .modalHeader h3 { + font-weight: 600; font-size: 24px; margin: 0; } diff --git a/src/Modal.jsx b/src/Modal.tsx similarity index 50% rename from src/Modal.jsx rename to src/Modal.tsx index f1d4d6e..686234a 100644 --- a/src/Modal.jsx +++ b/src/Modal.tsx @@ -1,29 +1,73 @@ -import React, { useRef, useMemo } from "react"; +/* +Copyright 2022 New Vector Ltd + +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. +*/ + +/* eslint-disable jsx-a11y/no-autofocus */ + +import React, { useRef, useMemo, ReactNode } from "react"; import { useOverlay, usePreventScroll, useModal, OverlayContainer, + OverlayProps, } from "@react-aria/overlays"; -import { useOverlayTriggerState } from "@react-stately/overlays"; +import { + OverlayTriggerState, + useOverlayTriggerState, +} from "@react-stately/overlays"; import { useDialog } from "@react-aria/dialog"; import { FocusScope } from "@react-aria/focus"; -import { useButton } from "@react-aria/button"; +import { ButtonAria, useButton } from "@react-aria/button"; +import classNames from "classnames"; +import { AriaDialogProps } from "@react-types/dialog"; + import { ReactComponent as CloseIcon } from "./icons/Close.svg"; import styles from "./Modal.module.css"; -import classNames from "classnames"; -export function Modal(props) { - const { title, children, className, mobileFullScreen } = props; +export interface ModalProps extends OverlayProps, AriaDialogProps { + title: string; + children: ReactNode; + className?: string; + mobileFullScreen?: boolean; + onClose?: () => void; +} + +export function Modal({ + title, + children, + className, + mobileFullScreen, + onClose, + ...rest +}: ModalProps) { const modalRef = useRef(); - const { overlayProps, underlayProps } = useOverlay(props, modalRef); + const { overlayProps, underlayProps } = useOverlay( + { ...rest, onClose }, + modalRef + ); usePreventScroll(); const { modalProps } = useModal(); - const { dialogProps, titleProps } = useDialog(props, modalRef); + const { dialogProps, titleProps } = useDialog(rest, modalRef); const closeButtonRef = useRef(); - const { buttonProps: closeButtonProps } = useButton({ - onPress: () => props.onClose(), - }); + const { buttonProps: closeButtonProps } = useButton( + { + onPress: () => onClose(), + }, + closeButtonRef + ); return ( @@ -58,7 +102,16 @@ export function Modal(props) { ); } -export function ModalContent({ children, className, ...rest }) { +interface ModalContentProps { + children: ReactNode; + className?: string; +} + +export function ModalContent({ + children, + className, + ...rest +}: ModalContentProps) { return (
    {children} @@ -66,7 +119,10 @@ export function ModalContent({ children, className, ...rest }) { ); } -export function useModalTriggerState() { +export function useModalTriggerState(): { + modalState: OverlayTriggerState; + modalProps: { isOpen: boolean; onClose: () => void }; +} { const modalState = useOverlayTriggerState({}); const modalProps = useMemo( () => ({ isOpen: modalState.isOpen, onClose: modalState.close }), @@ -75,7 +131,10 @@ export function useModalTriggerState() { return { modalState, modalProps }; } -export function useToggleModalButton(modalState, ref) { +export function useToggleModalButton( + modalState: OverlayTriggerState, + ref: React.RefObject +): ButtonAria> { return useButton( { onPress: () => modalState.toggle(), @@ -84,7 +143,10 @@ export function useToggleModalButton(modalState, ref) { ); } -export function useOpenModalButton(modalState, ref) { +export function useOpenModalButton( + modalState: OverlayTriggerState, + ref: React.RefObject +): ButtonAria> { return useButton( { onPress: () => modalState.open(), @@ -93,7 +155,10 @@ export function useOpenModalButton(modalState, ref) { ); } -export function useCloseModalButton(modalState, ref) { +export function useCloseModalButton( + modalState: OverlayTriggerState, + ref: React.RefObject +): ButtonAria> { return useButton( { onPress: () => modalState.close(), @@ -102,8 +167,12 @@ export function useCloseModalButton(modalState, ref) { ); } -export function ModalTrigger({ children }) { - const { modalState, modalProps } = useModalState(); +interface ModalTriggerProps { + children: ReactNode; +} + +export function ModalTrigger({ children }: ModalTriggerProps) { + const { modalState, modalProps } = useModalTriggerState(); const buttonRef = useRef(); const { buttonProps } = useToggleModalButton(modalState, buttonRef); diff --git a/src/SequenceDiagramViewerPage.jsx b/src/SequenceDiagramViewerPage.jsx deleted file mode 100644 index 3752dc2..0000000 --- a/src/SequenceDiagramViewerPage.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { SequenceDiagramViewer } from "./room/GroupCallInspector"; -import { FieldRow, InputField } from "./input/Input"; -import { usePageTitle } from "./usePageTitle"; - -export function SequenceDiagramViewerPage() { - usePageTitle("Inspector"); - - const [debugLog, setDebugLog] = useState(); - const [selectedUserId, setSelectedUserId] = useState(); - const onChangeDebugLog = useCallback((e) => { - if (e.target.files && e.target.files.length > 0) { - e.target.files[0].text().then((text) => { - setDebugLog(JSON.parse(text)); - }); - } - }, []); - - return ( -
    - - - - {debugLog && ( - - )} -
    - ); -} diff --git a/src/SequenceDiagramViewerPage.tsx b/src/SequenceDiagramViewerPage.tsx new file mode 100644 index 0000000..a6473cc --- /dev/null +++ b/src/SequenceDiagramViewerPage.tsx @@ -0,0 +1,67 @@ +/* +Copyright 2022 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useCallback, useState } from "react"; + +import { + SequenceDiagramViewer, + SequenceDiagramMatrixEvent, +} from "./room/GroupCallInspector"; +import { FieldRow, InputField } from "./input/Input"; +import { usePageTitle } from "./usePageTitle"; + +interface DebugLog { + localUserId: string; + eventsByUserId: { [userId: string]: SequenceDiagramMatrixEvent[] }; + remoteUserIds: string[]; +} + +export function SequenceDiagramViewerPage() { + usePageTitle("Inspector"); + + const [debugLog, setDebugLog] = useState(); + const [selectedUserId, setSelectedUserId] = useState(); + const onChangeDebugLog = useCallback((e) => { + if (e.target.files && e.target.files.length > 0) { + e.target.files[0].text().then((text: string) => { + setDebugLog(JSON.parse(text)); + }); + } + }, []); + + return ( +
    + + + + {debugLog && ( + + )} +
    + ); +} diff --git a/src/Tooltip.jsx b/src/Tooltip.jsx deleted file mode 100644 index 9f61308..0000000 --- a/src/Tooltip.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { forwardRef, useRef } from "react"; -import { useTooltipTriggerState } from "@react-stately/tooltip"; -import { FocusableProvider } from "@react-aria/focus"; -import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip"; -import { mergeProps, useObjectRef } from "@react-aria/utils"; -import styles from "./Tooltip.module.css"; -import classNames from "classnames"; -import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays"; - -export const Tooltip = forwardRef( - ({ position, state, className, ...props }, ref) => { - let { tooltipProps } = useTooltip(props, state); - - return ( -
    - {props.children} -
    - ); - } -); - -export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => { - const tooltipState = useTooltipTriggerState(rest); - const triggerRef = useObjectRef(ref); - const overlayRef = useRef(); - const { triggerProps, tooltipProps } = useTooltipTrigger( - rest, - tooltipState, - triggerRef - ); - - const { overlayProps } = useOverlayPosition({ - placement: rest.placement || "top", - targetRef: triggerRef, - overlayRef, - isOpen: tooltipState.isOpen, - offset: 5, - }); - - if ( - !Array.isArray(children) || - children.length > 2 || - typeof children[1] !== "function" - ) { - throw new Error( - "TooltipTrigger must have two props. The first being a button and the second being a render prop." - ); - } - - const [tooltipTrigger, tooltip] = children; - - return ( - - {} - {tooltipState.isOpen && ( - - - {tooltip()} - - - )} - - ); -}); - -TooltipTrigger.defaultProps = { - delay: 250, -}; diff --git a/src/Tooltip.tsx b/src/Tooltip.tsx new file mode 100644 index 0000000..14905d9 --- /dev/null +++ b/src/Tooltip.tsx @@ -0,0 +1,114 @@ +/* +Copyright 2022 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { + ForwardedRef, + forwardRef, + ReactElement, + ReactNode, + useRef, +} from "react"; +import { + TooltipTriggerState, + useTooltipTriggerState, +} from "@react-stately/tooltip"; +import { FocusableProvider } from "@react-aria/focus"; +import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip"; +import { mergeProps, useObjectRef } from "@react-aria/utils"; +import classNames from "classnames"; +import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays"; +import { Placement } from "@react-types/overlays"; + +import styles from "./Tooltip.module.css"; + +interface TooltipProps { + className?: string; + state: TooltipTriggerState; + children: ReactNode; +} + +export const Tooltip = forwardRef( + ( + { state, className, children, ...rest }: TooltipProps, + ref: ForwardedRef + ) => { + const { tooltipProps } = useTooltip(rest, state); + + return ( +
    + {children} +
    + ); + } +); + +interface TooltipTriggerProps { + children: ReactElement; + placement?: Placement; + delay?: number; + tooltip: () => string; +} + +export const TooltipTrigger = forwardRef( + ( + { children, placement, tooltip, ...rest }: TooltipTriggerProps, + ref: ForwardedRef + ) => { + const tooltipTriggerProps = { delay: 250, ...rest }; + const tooltipState = useTooltipTriggerState(tooltipTriggerProps); + const triggerRef = useObjectRef(ref); + const overlayRef = useRef(); + const { triggerProps, tooltipProps } = useTooltipTrigger( + tooltipTriggerProps, + tooltipState, + triggerRef + ); + + const { overlayProps } = useOverlayPosition({ + placement: placement || "top", + targetRef: triggerRef, + overlayRef, + isOpen: tooltipState.isOpen, + offset: 5, + }); + + return ( + + ( + children.props, + rest + )} + /> + {tooltipState.isOpen && ( + + + {tooltip()} + + + )} + + ); + } +); diff --git a/src/UserMenu.jsx b/src/UserMenu.tsx similarity index 83% rename from src/UserMenu.jsx rename to src/UserMenu.tsx index 6363948..83da187 100644 --- a/src/UserMenu.jsx +++ b/src/UserMenu.tsx @@ -1,16 +1,26 @@ import React, { useMemo } from "react"; import { Item } from "@react-stately/collections"; +import { useLocation } from "react-router-dom"; + import { Button, LinkButton } from "./button"; import { PopoverMenuTrigger } from "./popover/PopoverMenu"; import { Menu } from "./Menu"; -import { Tooltip, TooltipTrigger } from "./Tooltip"; -import { Avatar } from "./Avatar"; +import { TooltipTrigger } from "./Tooltip"; +import { Avatar, Size } from "./Avatar"; import { ReactComponent as UserIcon } from "./icons/User.svg"; import { ReactComponent as LoginIcon } from "./icons/Login.svg"; import { ReactComponent as LogoutIcon } from "./icons/Logout.svg"; -import styles from "./UserMenu.module.css"; -import { useLocation } from "react-router-dom"; import { Body } from "./typography/Typography"; +import styles from "./UserMenu.module.css"; + +interface UserMenuProps { + preventNavigation: boolean; + isAuthenticated: boolean; + isPasswordlessUser: boolean; + displayName: string; + avatarUrl: string; + onAction: (value: string) => void; +} export function UserMenu({ preventNavigation, @@ -19,7 +29,7 @@ export function UserMenu({ displayName, avatarUrl, onAction, -}) { +}: UserMenuProps) { const location = useLocation(); const items = useMemo(() => { @@ -62,11 +72,11 @@ export function UserMenu({ return ( - + "Profile"} placement="bottom left"> - {() => "Profile"} {(props) => ( {items.map(({ key, icon: Icon, label }) => ( - + {label} diff --git a/src/UserMenuContainer.jsx b/src/UserMenuContainer.tsx similarity index 90% rename from src/UserMenuContainer.jsx rename to src/UserMenuContainer.tsx index 18d52db..437d3b7 100644 --- a/src/UserMenuContainer.jsx +++ b/src/UserMenuContainer.tsx @@ -1,12 +1,17 @@ import React, { useCallback } from "react"; import { useHistory, useLocation } from "react-router-dom"; + import { useClient } from "./ClientContext"; import { useProfile } from "./profile/useProfile"; import { useModalTriggerState } from "./Modal"; import { ProfileModal } from "./profile/ProfileModal"; import { UserMenu } from "./UserMenu"; -export function UserMenuContainer({ preventNavigation }) { +interface Props { + preventNavigation?: boolean; +} + +export function UserMenuContainer({ preventNavigation = false }: Props) { const location = useLocation(); const history = useHistory(); const { isAuthenticated, isPasswordlessUser, logout, userName, client } = @@ -15,7 +20,7 @@ export function UserMenuContainer({ preventNavigation }) { const { modalState, modalProps } = useModalTriggerState(); const onAction = useCallback( - (value) => { + (value: string) => { switch (value) { case "user": modalState.open(); diff --git a/src/auth/RegisterPage.tsx b/src/auth/RegisterPage.tsx index fbae083..768c3c1 100644 --- a/src/auth/RegisterPage.tsx +++ b/src/auth/RegisterPage.tsx @@ -100,7 +100,7 @@ export const RegisterPage: FC = () => { submit() .then(() => { if (location.state?.from) { - history.push(location.state.from); + history.push(location.state?.from); } else { history.push("/"); } diff --git a/src/button/Button.tsx b/src/button/Button.tsx index bd7a0dd..2c2605f 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -30,7 +30,7 @@ import { ReactComponent as SettingsIcon } from "../icons/Settings.svg"; import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg"; import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg"; import { TooltipTrigger } from "../Tooltip"; -import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg"; +import { VolumeIcon } from "./VolumeIcon"; export type ButtonVariant = | "default" @@ -74,6 +74,7 @@ interface Props { children: Element[]; onPress: (e: PressEvent) => void; onPressStart: (e: PressEvent) => void; + // TODO: add all props for - {() => (muted ? "Unmute microphone" : "Mute microphone")} ); } @@ -153,14 +156,16 @@ export function VideoButton({ ...rest }: { muted: boolean; + // TODO: add all props for - {() => (muted ? "Turn on camera" : "Turn off camera")} ); } @@ -172,14 +177,16 @@ export function ScreenshareButton({ }: { enabled: boolean; className?: string; + // TODO: add all props for - {() => (enabled ? "Stop sharing screen" : "Share screen")} ); } @@ -189,10 +196,11 @@ export function HangupButton({ ...rest }: { className?: string; + // TODO: add all props for - {() => "Leave"} ); } @@ -210,14 +217,14 @@ export function SettingsButton({ ...rest }: { className?: string; + // TODO: add all props for - {() => "Settings"} ); } @@ -227,25 +234,31 @@ export function InviteButton({ ...rest }: { className?: string; + // TODO: add all props for - {() => "Invite"} ); } -export function OptionsButton(props: Omit) { +interface AudioButtonProps extends Omit { + /** + * A number between 0 and 1 + */ + volume: number; +} + +export function AudioButton({ volume, ...rest }: AudioButtonProps) { return ( - - - {() => "Options"} ); } diff --git a/src/button/CopyButton.tsx b/src/button/CopyButton.tsx index 480cd44..d6f159e 100644 --- a/src/button/CopyButton.tsx +++ b/src/button/CopyButton.tsx @@ -23,10 +23,10 @@ import { Button, ButtonVariant } from "./Button"; interface Props { value: string; - children: JSX.Element; - className: string; - variant: ButtonVariant; - copiedMessage: string; + children?: JSX.Element | string; + className?: string; + variant?: ButtonVariant; + copiedMessage?: string; } export function CopyButton({ value, diff --git a/src/button/LinkButton.tsx b/src/button/LinkButton.tsx index 0ed0cb1..1aaf541 100644 --- a/src/button/LinkButton.tsx +++ b/src/button/LinkButton.tsx @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { HTMLAttributes } from "react"; import { Link } from "react-router-dom"; import classNames from "classnames"; +import * as H from "history"; import { variantToClassName, @@ -24,19 +25,21 @@ import { ButtonVariant, ButtonSize, } from "./Button"; -interface Props { - className: string; - variant: ButtonVariant; - size: ButtonSize; - children: JSX.Element; - [index: string]: unknown; + +interface Props extends HTMLAttributes { + children: JSX.Element | string; + to: H.LocationDescriptor | ((location: H.Location) => H.LocationDescriptor); + size?: ButtonSize; + variant?: ButtonVariant; + className?: string; } export function LinkButton({ - className, - variant, - size, children, + to, + size, + variant, + className, ...rest }: Props) { return ( @@ -46,6 +49,7 @@ export function LinkButton({ sizeToClassName[size], className )} + to={to} {...rest} > {children} diff --git a/src/button/VolumeIcon.tsx b/src/button/VolumeIcon.tsx new file mode 100644 index 0000000..d4958e4 --- /dev/null +++ b/src/button/VolumeIcon.tsx @@ -0,0 +1,35 @@ +/* +Copyright 2022 New Vector Ltd + + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import { ReactComponent as AudioMuted } from "../icons/AudioMuted.svg"; +import { ReactComponent as AudioLow } from "../icons/AudioLow.svg"; +import { ReactComponent as Audio } from "../icons/Audio.svg"; + +interface Props { + /** + * Number between 0 and 1 + */ + volume: number; +} + +export function VolumeIcon({ volume }: Props) { + if (volume <= 0) return ; + if (volume <= 0.5) return ; + return