diff --git a/.env b/.env index 2785e05..f9e5c88 100644 --- a/.env +++ b/.env @@ -7,6 +7,9 @@ # Used for determining the homeserver to use for short urls etc. # VITE_DEFAULT_HOMESERVER=http://localhost:8008 +# Used for submitting debug logs to an external rageshake server +# VITE_RAGESHAKE_SUBMIT_URL=http://localhost:9110/api/submit + # The Sentry DSN to use for error reporting. Leave undefined to disable. # VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 diff --git a/.storybook/main.js b/.storybook/main.js index 207fe18..af43aa6 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -2,13 +2,20 @@ const svgrPlugin = require("vite-plugin-svgr"); const path = require("path"); module.exports = { - stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], - addons: ["@storybook/addon-links", "@storybook/addon-essentials"], + stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"], framework: "@storybook/react", core: { builder: "storybook-builder-vite", }, async viteFinal(config) { + config.plugins = config.plugins.filter( + (item) => + !( + Array.isArray(item) && + item.length > 0 && + item[0].name === "vite-plugin-mdx" + ) + ); config.plugins.push(svgrPlugin()); config.resolve = config.resolve || {}; config.resolve.alias = config.resolve.alias || {}; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e14c75d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +Contributing code to Element +============================ + +Element follows the same pattern as the [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md). diff --git a/index.html b/index.html index 72c59a7..8362664 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,21 @@ - - - - - Matrix Video Chat - - - -
- - - + + + + + + + <%- title %> + + + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json index 04bb997..55d8af1 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build-storybook": "build-storybook" }, "dependencies": { + "@juggle/resize-observer": "^3.3.1", "@react-aria/button": "^3.3.4", "@react-aria/dialog": "^3.1.4", "@react-aria/focus": "^3.5.0", @@ -29,7 +30,9 @@ "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", + "mermaid": "^8.13.8", "normalize.css": "^8.0.1", + "pako": "^2.0.4", "postcss-preset-env": "^6.7.0", "re-resizable": "^6.9.0", "react": "^17.0.0", @@ -37,18 +40,17 @@ "react-json-view": "^1.21.3", "react-router": "6", "react-router-dom": "^5.2.0", - "react-use-clipboard": "^1.0.7" + "react-use-clipboard": "^1.0.7", + "react-use-measure": "^2.1.1" }, "devDependencies": { "@babel/core": "^7.16.5", - "@storybook/addon-actions": "^6.5.0-alpha.5", - "@storybook/addon-essentials": "^6.5.0-alpha.5", - "@storybook/addon-links": "^6.5.0-alpha.5", "@storybook/react": "^6.5.0-alpha.5", "babel-loader": "^8.2.3", "sass": "^1.42.1", "storybook-builder-vite": "^0.1.12", "vite": "^2.4.2", + "vite-plugin-html": "^3.0.3", "vite-plugin-svgr": "^0.4.0" } } diff --git a/src/App.jsx b/src/App.jsx index 5dea73e..3c882c5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -25,6 +25,7 @@ import { RoomPage } from "./room/RoomPage"; import { RoomRedirect } from "./room/RoomRedirect"; import { ClientProvider } from "./ClientContext"; import { usePageFocusStyle } from "./usePageFocusStyle"; +import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage"; const SentryRoute = Sentry.withSentryRouting(Route); @@ -48,6 +49,9 @@ export default function App({ history }) { + + + diff --git a/src/Avatar.module.css b/src/Avatar.module.css index 516dca1..52b20a5 100644 --- a/src/Avatar.module.css +++ b/src/Avatar.module.css @@ -49,7 +49,7 @@ width: 42px; height: 42px; border-radius: 42px; - font-size: 36px; + font-size: 24px; } .xl { diff --git a/src/Header.jsx b/src/Header.jsx index c2d7db3..8f82185 100644 --- a/src/Header.jsx +++ b/src/Header.jsx @@ -7,6 +7,7 @@ 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"; export function Header({ children, className, ...rest }) { return ( @@ -60,6 +61,11 @@ export function RoomHeaderInfo({ roomName }) { return ( <>
+
{roomName} diff --git a/src/ListBox.module.css b/src/ListBox.module.css index 0af4d8a..7b2be37 100644 --- a/src/ListBox.module.css +++ b/src/ListBox.module.css @@ -23,10 +23,6 @@ min-height: 32px; } -.option.selected { - color: #0dbd8b; -} - .option.focused { background-color: rgba(111, 120, 130, 0.2); } diff --git a/src/Menu.module.css b/src/Menu.module.css index bb75cec..8291e58 100644 --- a/src/Menu.module.css +++ b/src/Menu.module.css @@ -16,7 +16,7 @@ } .menuItem > * { - margin-right: 10px; + margin: 0 10px 0 0; } .menuItem > :last-child { diff --git a/src/Modal.module.css b/src/Modal.module.css index 53c4314..bf654d2 100644 --- a/src/Modal.module.css +++ b/src/Modal.module.css @@ -52,6 +52,12 @@ } @media (max-width: 799px) { + .modalHeader { + display: flex; + justify-content: space-between; + padding: 24px 24px 0 24px; + } + .modal.mobileFullScreen { position: fixed; left: 0; diff --git a/src/SequenceDiagramViewerPage.jsx b/src/SequenceDiagramViewerPage.jsx new file mode 100644 index 0000000..3752dc2 --- /dev/null +++ b/src/SequenceDiagramViewerPage.jsx @@ -0,0 +1,41 @@ +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/Tooltip.jsx b/src/Tooltip.jsx index e9d3596..9f61308 100644 --- a/src/Tooltip.jsx +++ b/src/Tooltip.jsx @@ -1,32 +1,46 @@ -import React, { forwardRef } from "react"; +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 function Tooltip({ position, state, ...props }) { - let { tooltipProps } = useTooltip(props, state); +export const Tooltip = forwardRef( + ({ position, state, className, ...props }, ref) => { + let { tooltipProps } = useTooltip(props, state); - return ( -
- {props.children} -
- ); -} + 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 || @@ -40,13 +54,20 @@ export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => { const [tooltipTrigger, tooltip] = children; return ( -
- - {tooltipState.isOpen && tooltip({ state: tooltipState, ...tooltipProps })} -
+ + {} + {tooltipState.isOpen && ( + + + {tooltip()} + + + )} + ); }); diff --git a/src/Tooltip.module.css b/src/Tooltip.module.css index bbf7608..053bc89 100644 --- a/src/Tooltip.module.css +++ b/src/Tooltip.module.css @@ -1,6 +1,5 @@ .tooltip { background-color: var(--bgColor2); - position: absolute; flex-direction: row; justify-content: center; align-items: center; @@ -9,25 +8,5 @@ border-radius: 8px; max-width: 135px; width: max-content; - z-index: 1; - left: 50%; - transform: translateX(-50%); text-align: center; } - -.tooltip.top { - bottom: calc(100% + 6px); -} - -.tooltip.bottom { - top: calc(100% + 6px); -} - -.tooltip.bottomLeft { - top: calc(100% + 6px); - left: -25%; -} - -.tooltipContainer { - position: relative; -} diff --git a/src/UserMenu.jsx b/src/UserMenu.jsx index 8e83ca9..6363948 100644 --- a/src/UserMenu.jsx +++ b/src/UserMenu.jsx @@ -10,9 +10,10 @@ 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"; export function UserMenu({ - disableLogout, + preventNavigation, isAuthenticated, isPasswordlessUser, displayName, @@ -31,7 +32,7 @@ export function UserMenu({ label: displayName, }); - if (isPasswordlessUser) { + if (isPasswordlessUser && !preventNavigation) { arr.push({ key: "login", label: "Sign In", @@ -39,7 +40,7 @@ export function UserMenu({ }); } - if (!isPasswordlessUser && !disableLogout) { + if (!isPasswordlessUser && !preventNavigation) { arr.push({ key: "logout", label: "Sign Out", @@ -49,7 +50,7 @@ export function UserMenu({ } return arr; - }, [isAuthenticated, isPasswordlessUser, displayName, disableLogout]); + }, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation]); if (!isAuthenticated) { return ( @@ -61,9 +62,9 @@ export function UserMenu({ return ( - + - {(props) => ( - - Profile - - )} + {() => "Profile"} {(props) => ( {items.map(({ key, icon: Icon, label }) => ( - - - {label} + + + {label} ))} diff --git a/src/UserMenu.module.css b/src/UserMenu.module.css index 8cda2eb..30692a6 100644 --- a/src/UserMenu.module.css +++ b/src/UserMenu.module.css @@ -1,3 +1,8 @@ +.menuIcon { + width: 24px; + height: 24px; +} + .userButton svg * { fill: var(--textColor1); } diff --git a/src/UserMenuContainer.jsx b/src/UserMenuContainer.jsx index b4af921..eecb657 100644 --- a/src/UserMenuContainer.jsx +++ b/src/UserMenuContainer.jsx @@ -6,7 +6,7 @@ import { useModalTriggerState } from "./Modal"; import { ProfileModal } from "./profile/ProfileModal"; import { UserMenu } from "./UserMenu"; -export function UserMenuContainer({ disableLogout }) { +export function UserMenuContainer({ preventNavigation }) { const location = useLocation(); const history = useHistory(); const { isAuthenticated, isPasswordlessUser, logout, userName, client } = @@ -34,7 +34,7 @@ export function UserMenuContainer({ disableLogout }) { return ( <> {" "} apply.
- By clicking "Go", you agree to our{" "} + By clicking "Log in", you agree to our{" "} Terms and conditions )} diff --git a/src/auth/useInteractiveRegistration.js b/src/auth/useInteractiveRegistration.js index 57e2178..fc398a1 100644 --- a/src/auth/useInteractiveRegistration.js +++ b/src/auth/useInteractiveRegistration.js @@ -66,6 +66,8 @@ export function useInteractiveRegistration() { deviceId: device_id, }); + await client.setDisplayName(username); + const session = { user_id, device_id, access_token, passwordlessUser }; if (passwordlessUser) { diff --git a/src/auth/useRecaptcha.js b/src/auth/useRecaptcha.js index c7dc4b4..9507a02 100644 --- a/src/auth/useRecaptcha.js +++ b/src/auth/useRecaptcha.js @@ -57,6 +57,7 @@ export function useRecaptcha(sitekey) { } if (!window.grecaptcha) { + console.log("Recaptcha not loaded"); return Promise.reject(new Error("Recaptcha not loaded")); } @@ -94,7 +95,7 @@ export function useRecaptcha(sitekey) { }); } }); - }, [recaptchaId]); + }, [recaptchaId, sitekey]); const reset = useCallback(() => { if (window.grecaptcha) { diff --git a/src/button/Button.jsx b/src/button/Button.jsx index a98fbb6..b28420d 100644 --- a/src/button/Button.jsx +++ b/src/button/Button.jsx @@ -34,12 +34,17 @@ export const Button = forwardRef( iconStyle, className, children, + onPress, + onPressStart, ...rest }, ref ) => { const buttonRef = useObjectRef(ref); - const { buttonProps } = useButton(rest, buttonRef); + const { buttonProps } = useButton( + { onPress, onPressStart, ...rest }, + buttonRef + ); // TODO: react-aria's useButton hook prevents form submission via keyboard // Remove the hack below after this is merged https://github.com/adobe/react-spectrum/pull/904 @@ -71,25 +76,13 @@ export const Button = forwardRef( } ); -export function ButtonTooltip({ className, children }) { - return ( -
- {children} -
- ); -} - export function MicButton({ muted, ...rest }) { return ( - {(props) => ( - - {muted ? "Unmute microphone" : "Mute microphone"} - - )} + {() => (muted ? "Unmute microphone" : "Mute microphone")} ); } @@ -100,11 +93,7 @@ export function VideoButton({ muted, ...rest }) { - {(props) => ( - - {muted ? "Turn on camera" : "Turn off camera"} - - )} + {() => (muted ? "Turn on camera" : "Turn off camera")}
); } @@ -115,11 +104,7 @@ export function ScreenshareButton({ enabled, className, ...rest }) { - {(props) => ( - - {enabled ? "Stop sharing screen" : "Share screen"} - - )} + {() => (enabled ? "Stop sharing screen" : "Share screen")} ); } @@ -134,11 +119,7 @@ export function HangupButton({ className, ...rest }) { > - {(props) => ( - - Leave - - )} + {() => "Leave"} ); } diff --git a/src/button/Button.module.css b/src/button/Button.module.css index 193395c..c8e0e44 100644 --- a/src/button/Button.module.css +++ b/src/button/Button.module.css @@ -46,6 +46,15 @@ limitations under the License. background-color: var(--primaryColor); } +.button:focus, +.toolbarButton:focus, +.iconButton:focus, +.iconCopyButton:focus, +.secondary:focus, +.copyButton:focus { + outline: auto; +} + .toolbarButton { width: 50px; height: 50px; @@ -91,35 +100,6 @@ limitations under the License. fill: #21262c; } -.buttonTooltip { - display: none; - background-color: var(--bgColor2); - position: absolute; - flex-direction: row; - justify-content: center; - align-items: center; - padding: 8px 10px; - color: var(--textColor1); - border-radius: 8px; - max-width: 135px; - width: max-content; - z-index: 1; -} - -.buttonTooltip.bottomRight { - right: 0; -} - -.toolbarButton:hover .buttonTooltip { - display: flex; - bottom: calc(100% + 6px); -} - -.iconButton:hover .buttonTooltip { - display: flex; - top: calc(100% + 6px); -} - .secondary, .copyButton { color: #0dbd8b; diff --git a/src/home/CallList.jsx b/src/home/CallList.jsx index 35b4da6..55469b9 100644 --- a/src/home/CallList.jsx +++ b/src/home/CallList.jsx @@ -8,7 +8,7 @@ import styles from "./CallList.module.css"; import { getRoomUrl } from "../matrix-utils"; import { Body, Caption } from "../typography/Typography"; -export function CallList({ rooms, client }) { +export function CallList({ rooms, client, disableFacepile }) { return ( <>
@@ -20,6 +20,7 @@ export function CallList({ rooms, client }) { avatarUrl={avatarUrl} roomId={roomId} participants={participants} + disableFacepile={disableFacepile} /> ))} {rooms.length > 3 && ( @@ -33,7 +34,14 @@ export function CallList({ rooms, client }) { ); } -function CallTile({ name, avatarUrl, roomId, participants, client }) { +function CallTile({ + name, + avatarUrl, + roomId, + participants, + client, + disableFacepile, +}) { return (
@@ -41,7 +49,7 @@ function CallTile({ name, avatarUrl, roomId, participants, client }) { size="lg" bgKey={name} src={avatarUrl} - fallback={} + fallback={name.slice(0, 1).toUpperCase()} className={styles.avatar} />
@@ -49,7 +57,7 @@ function CallTile({ name, avatarUrl, roomId, participants, client }) { {name} {getRoomUrl(roomId)} - {participants && ( + {participants && !disableFacepile && ( Your recent Calls - + )} diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx index f9af6b8..fb6fe42 100644 --- a/src/home/UnauthenticatedView.jsx +++ b/src/home/UnauthenticatedView.jsx @@ -102,8 +102,8 @@ export function UnauthenticatedView() { - + + + , document.getElementById("root") diff --git a/src/popover/PopoverMenu.jsx b/src/popover/PopoverMenu.jsx index 1436b99..66ed3a7 100644 --- a/src/popover/PopoverMenu.jsx +++ b/src/popover/PopoverMenu.jsx @@ -1,72 +1,68 @@ -import React, { useRef } from "react"; +import React, { forwardRef, useRef } from "react"; import styles from "./PopoverMenu.module.css"; import { useMenuTriggerState } from "@react-stately/menu"; import { useMenuTrigger } from "@react-aria/menu"; import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays"; +import { mergeProps, useObjectRef } from "@react-aria/utils"; import classNames from "classnames"; import { Popover } from "./Popover"; -export function PopoverMenuTrigger({ - children, - placement, - className, - disableOnState, - ...rest -}) { - const popoverMenuState = useMenuTriggerState(rest); - const buttonRef = useRef(); - const { menuTriggerProps, menuProps } = useMenuTrigger( - {}, - popoverMenuState, - buttonRef - ); +export const PopoverMenuTrigger = forwardRef( + ({ children, placement, className, disableOnState, ...rest }, ref) => { + const popoverMenuState = useMenuTriggerState(rest); + const buttonRef = useObjectRef(ref); + const { menuTriggerProps, menuProps } = useMenuTrigger( + {}, + popoverMenuState, + buttonRef + ); - const popoverRef = useRef(); + const popoverRef = useRef(); - const { overlayProps } = useOverlayPosition({ - targetRef: buttonRef, - overlayRef: popoverRef, - placement: placement || "top", - offset: 5, - isOpen: popoverMenuState.isOpen, - }); + const { overlayProps } = useOverlayPosition({ + targetRef: buttonRef, + overlayRef: popoverRef, + placement: placement || "top", + offset: 5, + isOpen: popoverMenuState.isOpen, + }); - if ( - !Array.isArray(children) || - children.length > 2 || - typeof children[1] !== "function" - ) { - throw new Error( - "PopoverMenu must have two props. The first being a button and the second being a render prop." + if ( + !Array.isArray(children) || + children.length > 2 || + typeof children[1] !== "function" + ) { + throw new Error( + "PopoverMenu must have two props. The first being a button and the second being a render prop." + ); + } + + const [popoverTrigger, popoverMenu] = children; + + return ( +
+ + {popoverMenuState.isOpen && ( + + + {popoverMenu({ + ...menuProps, + autoFocus: popoverMenuState.focusStrategy, + onClose: popoverMenuState.close, + })} + + + )} +
); } - - const [popoverTrigger, popoverMenu] = children; - - return ( -
- - {popoverMenuState.isOpen && ( - - - {popoverMenu({ - ...menuProps, - autoFocus: popoverMenuState.focusStrategy, - onClose: popoverMenuState.close, - })} - - - )} -
- ); -} +); diff --git a/src/profile/ProfileModal.jsx b/src/profile/ProfileModal.jsx index 3a4b5dc..8207c6c 100644 --- a/src/profile/ProfileModal.jsx +++ b/src/profile/ProfileModal.jsx @@ -36,7 +36,7 @@ export function ProfileModal({ saveProfile({ displayName, - avatar, + avatar: avatar && avatar.size > 0 ? avatar : undefined, }); }, [saveProfile] @@ -52,6 +52,16 @@ export function ProfileModal({
+ + + - {isAuthenticated && !isPasswordlessUser && ( + {isAuthenticated && ( {layout === "spotlight" ? : } - {(props) => ( - - Layout Type - - )} + {() => "Layout Type"} {(props) => ( diff --git a/src/room/GroupCallInspector.jsx b/src/room/GroupCallInspector.jsx index aec4509..062b3ce 100644 --- a/src/room/GroupCallInspector.jsx +++ b/src/room/GroupCallInspector.jsx @@ -1,7 +1,17 @@ import { Resizable } from "re-resizable"; -import React, { useEffect, useState, useMemo } from "react"; -import { useCallback } from "react"; +import React, { + useEffect, + useState, + useReducer, + useRef, + createContext, + useContext, +} from "react"; import ReactJson from "react-json-view"; +import mermaid from "mermaid"; +import styles from "./GroupCallInspector.module.css"; +import { SelectInput } from "../input/SelectInput"; +import { Item } from "@react-stately/collections"; function getCallUserId(call) { return call.getOpponentMember()?.userId || call.invitee || null; @@ -23,203 +33,434 @@ function getHangupCallState(call) { }; } -export function GroupCallInspector({ client, groupCall, show }) { - const [roomStateEvents, setRoomStateEvents] = useState([]); - const [toDeviceEvents, setToDeviceEvents] = useState([]); - const [sentVoipEvents, setSentVoipEvents] = useState([]); - const [state, setState] = useState({ - userId: client.getUserId(), - }); +const dateFormatter = new Intl.DateTimeFormat([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, +}); - const updateState = useCallback( - (next) => setState((prev) => ({ ...prev, ...next })), - [] +const defaultCollapsedFields = [ + "org.matrix.msc3401.call", + "org.matrix.msc3401.call.member", + "calls", + "callStats", + "hangupCalls", + "toDeviceEvents", + "sentVoipEvents", + "content", +]; + +function shouldCollapse({ name, src, type, namespace }) { + return defaultCollapsedFields.includes(name); +} + +function getUserName(userId) { + const match = userId.match(/@([^\:]+):/); + + return match && match.length > 0 + ? match[1].replace("-", " ").replace("W", "") + : userId.replace("W", ""); +} + +function formatContent(type, content) { + if (type === "m.call.hangup") { + return `callId: ${content.call_id.slice(-4)} reason: ${ + content.reason + } senderSID: ${content.sender_session_id} destSID: ${ + content.dest_session_id + }`; + } + if (type.startsWith("m.call.")) { + return `callId: ${content.call_id?.slice(-4)} senderSID: ${ + content.sender_session_id + } destSID: ${content.dest_session_id}`; + } else if (type === "org.matrix.msc3401.call.member") { + const call = + content["m.calls"] && + content["m.calls"].length > 0 && + content["m.calls"][0]; + const device = + call && + call["m.devices"] && + call["m.devices"].length > 0 && + call["m.devices"][0]; + return `conf_id: ${call && call["m.call_id"].slice(-4)} sessionId: ${ + device && device.session_id + }`; + } else { + return ""; + } +} + +function formatTimestamp(timestamp) { + return dateFormatter.format(timestamp); +} + +export const InspectorContext = createContext(); + +export function InspectorContextProvider({ children }) { + const context = useState({}); + return ( + + {children} + ); +} + +export function SequenceDiagramViewer({ + localUserId, + remoteUserIds, + selectedUserId, + onSelectUserId, + events, +}) { + const mermaidElRef = useRef(); + + useEffect(() => { + mermaid.initialize({ + startOnLoad: true, + theme: "dark", + sequence: { + showSequenceNumbers: true, + }, + }); + }, []); + + useEffect(() => { + const graphDefinition = `sequenceDiagram + participant ${getUserName(localUserId)} + participant Room + participant ${selectedUserId ? getUserName(selectedUserId) : "unknown"} + ${ + events + ? events + .map( + ({ to, from, timestamp, type, content, ignored }) => + `${getUserName(from)} ${ignored ? "-x" : "->>"} ${getUserName( + to + )}: ${formatTimestamp(timestamp)} ${type} ${formatContent( + type, + content + )}` + ) + .join("\n ") + : "" + } + `; + + mermaid.mermaidAPI.render("mermaid", graphDefinition, (svgCode) => { + mermaidElRef.current.innerHTML = svgCode; + }); + }, [events, localUserId, selectedUserId]); + + return ( +
+
+ + {remoteUserIds.map((userId) => ( + {userId} + ))} + +
+
+
+
+ ); +} + +function reducer(state, action) { + switch (action.type) { + case "receive_room_state_event": { + const { event, callStateEvent, memberStateEvents } = action; + + let eventsByUserId = state.eventsByUserId; + let remoteUserIds = state.remoteUserIds; + + if (event) { + const fromId = event.getStateKey(); + + remoteUserIds = + fromId === state.localUserId || eventsByUserId[fromId] + ? state.remoteUserIds + : [...state.remoteUserIds, fromId]; + + eventsByUserId = { ...state.eventsByUserId }; + + if (event.getStateKey() === state.localUserId) { + for (const userId in eventsByUserId) { + eventsByUserId[userId] = [ + ...(eventsByUserId[userId] || []), + { + from: fromId, + to: "Room", + type: event.getType(), + content: event.getContent(), + timestamp: event.getTs() || Date.now(), + ignored: false, + }, + ]; + } + } else { + eventsByUserId[fromId] = [ + ...(eventsByUserId[fromId] || []), + { + from: fromId, + to: "Room", + type: event.getType(), + content: event.getContent(), + timestamp: event.getTs() || Date.now(), + ignored: false, + }, + ]; + } + } + + return { + ...state, + eventsByUserId, + remoteUserIds, + callStateEvent: callStateEvent.getContent(), + memberStateEvents: Object.fromEntries( + memberStateEvents.map((e) => [e.getStateKey(), e.getContent()]) + ), + }; + } + case "receive_to_device_event": { + const event = action.event; + const eventsByUserId = { ...state.eventsByUserId }; + const fromId = event.getSender(); + const toId = state.localUserId; + const content = event.getContent(); + + const remoteUserIds = eventsByUserId[fromId] + ? state.remoteUserIds + : [...state.remoteUserIds, fromId]; + + eventsByUserId[fromId] = [ + ...(eventsByUserId[fromId] || []), + { + from: fromId, + to: toId, + type: event.getType(), + content, + timestamp: event.getTs() || Date.now(), + ignored: state.localSessionId !== content.dest_session_id, + }, + ]; + + return { ...state, eventsByUserId, remoteUserIds }; + } + case "send_voip_event": { + const event = action.event; + const eventsByUserId = { ...state.eventsByUserId }; + const fromId = state.localUserId; + const toId = event.userId; + + const remoteUserIds = eventsByUserId[toId] + ? state.remoteUserIds + : [...state.remoteUserIds, toId]; + + eventsByUserId[toId] = [ + ...(eventsByUserId[toId] || []), + { + from: fromId, + to: toId, + type: event.eventType, + content: event.content, + timestamp: Date.now(), + ignored: false, + }, + ]; + + return { ...state, eventsByUserId, remoteUserIds }; + } + default: + return state; + } +} + +function useGroupCallState(client, groupCall, pollCallStats) { + const [state, dispatch] = useReducer(reducer, { + localUserId: client.getUserId(), + localSessionId: client.getSessionId(), + eventsByUserId: {}, + remoteUserIds: [], + callStateEvent: null, + memberStateEvents: {}, + }); useEffect(() => { function onUpdateRoomState(event) { - if (event) { - setRoomStateEvents((prev) => [ - ...prev, - { - eventType: event.getType(), - stateKey: event.getStateKey(), - content: event.getContent(), - }, - ]); - } - - const roomEvent = groupCall.room.currentState - .getStateEvents("org.matrix.msc3401.call", groupCall.groupCallId) - .getContent(); - - const memberEvents = Object.fromEntries( - groupCall.room.currentState - .getStateEvents("org.matrix.msc3401.call.member") - .map((event) => [event.getStateKey(), event.getContent()]) + const callStateEvent = groupCall.room.currentState.getStateEvents( + "org.matrix.msc3401.call", + groupCall.groupCallId ); - updateState({ - ["org.matrix.msc3401.call"]: roomEvent, - ["org.matrix.msc3401.call.member"]: memberEvents, + const memberStateEvents = groupCall.room.currentState.getStateEvents( + "org.matrix.msc3401.call.member" + ); + + dispatch({ + type: "receive_room_state_event", + event, + callStateEvent, + memberStateEvents, }); } - function onCallsChanged() { - const calls = groupCall.calls.reduce((obj, call) => { - obj[ - `${call.callId} (${call.getOpponentMember()?.userId || call.sender})` - ] = getCallState(call); - return obj; - }, {}); + // function onCallsChanged() { + // const calls = groupCall.calls.reduce((obj, call) => { + // obj[ + // `${call.callId} (${call.getOpponentMember()?.userId || call.sender})` + // ] = getCallState(call); + // return obj; + // }, {}); - updateState({ calls }); - } + // updateState({ calls }); + // } - function onCallHangup(call) { - setState(({ hangupCalls, ...rest }) => ({ - ...rest, - hangupCalls: { - ...hangupCalls, - [`${call.callId} (${ - call.getOpponentMember()?.userId || call.sender - })`]: getHangupCallState(call), - }, - })); - } + // function onCallHangup(call) { + // setState(({ hangupCalls, ...rest }) => ({ + // ...rest, + // hangupCalls: { + // ...hangupCalls, + // [`${call.callId} (${ + // call.getOpponentMember()?.userId || call.sender + // })`]: getHangupCallState(call), + // }, + // })); + // dispatch({ type: "call_hangup", call }); + // } function onToDeviceEvent(event) { - const eventType = event.getType(); - - if ( - !( - eventType.startsWith("m.call.") || - eventType.startsWith("org.matrix.call.") - ) - ) { - return; - } - - const content = event.getContent(); - - if (content.conf_id && content.conf_id !== groupCall.groupCallId) { - return; - } - - setToDeviceEvents((prev) => [ - ...prev, - { eventType, content, sender: event.getSender() }, - ]); + dispatch({ type: "receive_to_device_event", event }); } function onSendVoipEvent(event) { - setSentVoipEvents((prev) => [...prev, event]); + dispatch({ type: "send_voip_event", event }); } client.on("RoomState.events", onUpdateRoomState); - groupCall.on("calls_changed", onCallsChanged); + //groupCall.on("calls_changed", onCallsChanged); groupCall.on("send_voip_event", onSendVoipEvent); - client.on("state", onCallsChanged); - client.on("hangup", onCallHangup); + //client.on("state", onCallsChanged); + //client.on("hangup", onCallHangup); client.on("toDeviceEvent", onToDeviceEvent); onUpdateRoomState(); - }, [client, groupCall]); - - const toDeviceEventsByCall = useMemo(() => { - const result = {}; - - for (const event of toDeviceEvents) { - const callId = event.content.call_id; - const key = `${callId} (${event.sender})`; - result[key] = result[key] || []; - result[key].push(event); - } - - return result; - }, [toDeviceEvents]); - - const sentVoipEventsByCall = useMemo(() => { - const result = {}; - - for (const event of sentVoipEvents) { - const callId = event.content.call_id; - const key = `${callId} (${event.userId})`; - result[key] = result[key] || []; - result[key].push(event); - } - - return result; - }, [sentVoipEvents]); - - useEffect(() => { - let timeout; - - async function updateCallStats() { - const callIds = groupCall.calls.map( - (call) => - `${call.callId} (${call.getOpponentMember()?.userId || call.sender})` - ); - const stats = await Promise.all( - groupCall.calls.map((call) => - call.peerConn - ? call.peerConn - .getStats(null) - .then((stats) => - Object.fromEntries( - Array.from(stats).map(([_id, report], i) => [ - report.type + i, - report, - ]) - ) - ) - : Promise.resolve(null) - ) - ); - - const callStats = {}; - - for (let i = 0; i < groupCall.calls.length; i++) { - callStats[callIds[i]] = stats[i]; - } - - updateState({ callStats }); - timeout = setTimeout(updateCallStats, 1000); - } - - if (show) { - updateCallStats(); - } return () => { - clearTimeout(timeout); + client.removeListener("RoomState.events", onUpdateRoomState); + //groupCall.removeListener("calls_changed", onCallsChanged); + groupCall.removeListener("send_voip_event", onSendVoipEvent); + //client.removeListener("state", onCallsChanged); + //client.removeListener("hangup", onCallHangup); + client.removeListener("toDeviceEvent", onToDeviceEvent); }; - }, [show]); + }, [client, groupCall]); + + // useEffect(() => { + // let timeout; + + // async function updateCallStats() { + // const callIds = groupCall.calls.map( + // (call) => + // `${call.callId} (${call.getOpponentMember()?.userId || call.sender})` + // ); + // const stats = await Promise.all( + // groupCall.calls.map((call) => + // call.peerConn + // ? call.peerConn + // .getStats(null) + // .then((stats) => + // Object.fromEntries( + // Array.from(stats).map(([_id, report], i) => [ + // report.type + i, + // report, + // ]) + // ) + // ) + // : Promise.resolve(null) + // ) + // ); + + // const callStats = {}; + + // for (let i = 0; i < groupCall.calls.length; i++) { + // callStats[callIds[i]] = stats[i]; + // } + + // dispatch({ type: "callStats", callStats }); + // timeout = setTimeout(updateCallStats, 1000); + // } + + // if (pollCallStats) { + // updateCallStats(); + // } + + // return () => { + // clearTimeout(timeout); + // }; + // }, [pollCallStats]); + + return state; +} + +export function GroupCallInspector({ client, groupCall, show }) { + const [currentTab, setCurrentTab] = useState("sequence-diagrams"); + const [selectedUserId, setSelectedUserId] = useState(); + const state = useGroupCallState(client, groupCall, show); + + const [_, setState] = useContext(InspectorContext); + + useEffect(() => { + setState({ json: state }); + }, [setState, state]); if (!show) { return null; } return ( - - + +
+ + +
+ {currentTab === "sequence-diagrams" && ( + + )} + {currentTab === "inspector" && ( + + )}
); } diff --git a/src/room/GroupCallInspector.module.css b/src/room/GroupCallInspector.module.css new file mode 100644 index 0000000..d4e5103 --- /dev/null +++ b/src/room/GroupCallInspector.module.css @@ -0,0 +1,25 @@ +.inspector { + background-color: var(--bgColor2); +} + +.scrollContainer { + height: 100%; + overflow-y: auto; +} + +.sequenceDiagramViewer { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; +} + +.selectInput { + align-self: flex-start; +} + +.sequenceDiagramViewer :global(.messageText) { + font-size: 12px; + fill: var(--textColor1) !important; + stroke: var(--textColor1) !important; +} diff --git a/src/room/GroupCallLoader.jsx b/src/room/GroupCallLoader.jsx index 15b1acc..6fff1b7 100644 --- a/src/room/GroupCallLoader.jsx +++ b/src/room/GroupCallLoader.jsx @@ -1,6 +1,7 @@ import React from "react"; import { useLoadGroupCall } from "./useLoadGroupCall"; import { ErrorView, FullScreenView } from "../FullScreenView"; +import { usePageTitle } from "../usePageTitle"; export function GroupCallLoader({ client, roomId, viaServers, children }) { const { loading, error, groupCall } = useLoadGroupCall( @@ -9,6 +10,8 @@ export function GroupCallLoader({ client, roomId, viaServers, children }) { viaServers ); + usePageTitle(groupCall ? groupCall.room.name : "Loading..."); + if (loading) { return ( diff --git a/src/room/GroupCallView.jsx b/src/room/GroupCallView.jsx index 1d1641d..5808f1f 100644 --- a/src/room/GroupCallView.jsx +++ b/src/room/GroupCallView.jsx @@ -15,7 +15,19 @@ export function GroupCallView({ groupCall, simpleGrid, }) { - const [showInspector, setShowInspector] = useState(false); + const [showInspector, setShowInspector] = useState( + () => !!localStorage.getItem("matrix-group-call-inspector") + ); + const onChangeShowInspector = useCallback((show) => { + setShowInspector(show); + + if (show) { + localStorage.setItem("matrix-group-call-inspector", "true"); + } else { + localStorage.removeItem("matrix-group-call-inspector"); + } + }, []); + const { state, error, @@ -46,12 +58,11 @@ export function GroupCallView({ const history = useHistory(); const onLeave = useCallback(() => { + setLeft(true); leave(); if (!isPasswordlessUser) { history.push("/"); - } else { - setLeft(true); } }, [leave, history]); @@ -75,7 +86,7 @@ export function GroupCallView({ localScreenshareFeed={localScreenshareFeed} screenshareFeeds={screenshareFeeds} simpleGrid={simpleGrid} - setShowInspector={setShowInspector} + setShowInspector={onChangeShowInspector} showInspector={showInspector} roomId={roomId} /> @@ -102,7 +113,7 @@ export function GroupCallView({ localVideoMuted={localVideoMuted} toggleLocalVideoMuted={toggleLocalVideoMuted} toggleMicrophoneMuted={toggleMicrophoneMuted} - setShowInspector={setShowInspector} + setShowInspector={onChangeShowInspector} showInspector={showInspector} roomId={roomId} /> diff --git a/src/room/InCallView.jsx b/src/room/InCallView.jsx index bfcfa8e..8f84deb 100644 --- a/src/room/InCallView.jsx +++ b/src/room/InCallView.jsx @@ -128,7 +128,7 @@ export function InCallView({ - + {items.length === 0 ? ( diff --git a/src/room/LobbyView.jsx b/src/room/LobbyView.jsx index 68249b1..4921c2d 100644 --- a/src/room/LobbyView.jsx +++ b/src/room/LobbyView.jsx @@ -9,6 +9,11 @@ import { getRoomUrl } from "../matrix-utils"; import { OverflowMenu } from "./OverflowMenu"; import { UserMenuContainer } from "../UserMenuContainer"; import { Body, Link } from "../typography/Typography"; +import { Avatar } from "../Avatar"; +import { getAvatarUrl } from "../matrix-utils"; +import { useProfile } from "../profile/useProfile"; +import useMeasure from "react-use-measure"; +import { ResizeObserver } from "@juggle/resize-observer"; export function LobbyView({ client, @@ -27,9 +32,11 @@ export function LobbyView({ }) { const { stream } = useCallFeed(localCallFeed); const videoRef = useMediaStream(stream, true); + const { displayName, avatarUrl } = useProfile(client); + const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver }); + const avatarSize = (previewBounds.height - 66) / 2; useEffect(() => { - // TODO: Only init once onInitLocalCallFeed(); }, [onInitLocalCallFeed]); @@ -45,7 +52,7 @@ export function LobbyView({
-
+