Merge branch 'main' into robertlong/spotlight-layout

This commit is contained in:
Robert Long 2022-02-02 15:15:39 -08:00
commit dec47d21c0
53 changed files with 3531 additions and 2987 deletions

3
.env
View File

@ -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

View File

@ -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 || {};

4
CONTRIBUTING.md Normal file
View File

@ -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).

View File

@ -1,16 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>Matrix Video Chat</title>
<script>
window.global = window;
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>
<%- title %>
</title>
<script>
window.global = window;
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -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"
}
}

View File

@ -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 }) {
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>

View File

@ -49,7 +49,7 @@
width: 42px;
height: 42px;
border-radius: 42px;
font-size: 36px;
font-size: 24px;
}
.xl {

View File

@ -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 (
<>
<div className={styles.roomAvatar}>
<Avatar
size="md"
bgKey={roomName}
fallback={roomName.slice(0, 1).toUpperCase()}
/>
<VideoIcon width={16} height={16} />
</div>
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>

View File

@ -23,10 +23,6 @@
min-height: 32px;
}
.option.selected {
color: #0dbd8b;
}
.option.focused {
background-color: rgba(111, 120, 130, 0.2);
}

View File

@ -16,7 +16,7 @@
}
.menuItem > * {
margin-right: 10px;
margin: 0 10px 0 0;
}
.menuItem > :last-child {

View File

@ -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;

View File

@ -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 (
<div style={{ marginTop: 20 }}>
<FieldRow>
<InputField
type="file"
id="debugLog"
name="debugLog"
label="Debug Log"
onChange={onChangeDebugLog}
/>
</FieldRow>
{debugLog && (
<SequenceDiagramViewer
localUserId={debugLog.localUserId}
selectedUserId={selectedUserId}
onSelectUserId={setSelectedUserId}
remoteUserIds={debugLog.remoteUserIds}
events={debugLog.eventsByUserId[selectedUserId]}
/>
)}
</div>
);
}

View File

@ -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 (
<div
className={classNames(styles.tooltip, styles[position || "bottom"])}
{...mergeProps(props, tooltipProps)}
>
{props.children}
</div>
);
}
return (
<div
className={classNames(styles.tooltip, className)}
{...mergeProps(props, tooltipProps)}
ref={ref}
>
{props.children}
</div>
);
}
);
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 (
<div className={styles.tooltipContainer}>
<tooltipTrigger.type
{...mergeProps(triggerProps, tooltipTrigger.props, rest)}
ref={triggerRef}
/>
{tooltipState.isOpen && tooltip({ state: tooltipState, ...tooltipProps })}
</div>
<FocusableProvider ref={triggerRef} {...triggerProps}>
{<tooltipTrigger.type {...mergeProps(tooltipTrigger.props, rest)} />}
{tooltipState.isOpen && (
<OverlayContainer>
<Tooltip
state={tooltipState}
{...mergeProps(tooltipProps, overlayProps)}
ref={overlayRef}
>
{tooltip()}
</Tooltip>
</OverlayContainer>
)}
</FocusableProvider>
);
});

View File

@ -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;
}

View File

@ -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 (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger>
<TooltipTrigger placement="bottom left">
<Button variant="icon" className={styles.userButton}>
{isAuthenticated && !isPasswordlessUser ? (
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
<Avatar
size="sm"
className={styles.avatar}
@ -74,18 +75,14 @@ export function UserMenu({
<UserIcon />
)}
</Button>
{(props) => (
<Tooltip position="bottomLeft" {...props}>
Profile
</Tooltip>
)}
{() => "Profile"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="User menu" onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
<Item key={key} textValue={label}>
<Icon />
<span>{label}</span>
<Item key={key} textValue={label} className={styles.menuItem}>
<Icon width={24} height={24} className={styles.menuIcon} />
<Body overflowEllipsis>{label}</Body>
</Item>
))}
</Menu>

View File

@ -1,3 +1,8 @@
.menuIcon {
width: 24px;
height: 24px;
}
.userButton svg * {
fill: var(--textColor1);
}

View File

@ -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 (
<>
<UserMenu
disableLogout={disableLogout}
preventNavigation={preventNavigation}
isAuthenticated={isAuthenticated}
isPasswordlessUser={isPasswordlessUser}
avatarUrl={avatarUrl}

View File

@ -22,8 +22,11 @@ import { Button } from "../button";
import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";
export function LoginPage() {
usePageTitle("Login");
const [_, login] = useInteractiveLogin();
const [homeserver, setHomeServer] = useState(defaultHomeserver);
const usernameRef = useRef();

View File

@ -1,6 +1,7 @@
.logo {
max-width: 300px;
margin: 80px 0;
height: auto;
}
.container {

View File

@ -26,8 +26,11 @@ import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { LoadingView } from "../FullScreenView";
import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle";
export function RegisterPage() {
usePageTitle("Register");
const {
loading,
client,
@ -177,7 +180,7 @@ export function RegisterPage() {
</Link>{" "}
apply.
<br />
By clicking "Go", you agree to our{" "}
By clicking "Log in", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Caption>
)}

View File

@ -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) {

View File

@ -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) {

View File

@ -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 (
<div className={classNames(styles.buttonTooltip, className)}>
{children}
</div>
);
}
export function MicButton({ muted, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{muted ? "Unmute microphone" : "Mute microphone"}
</Tooltip>
)}
{() => (muted ? "Unmute microphone" : "Mute microphone")}
</TooltipTrigger>
);
}
@ -100,11 +93,7 @@ export function VideoButton({ muted, ...rest }) {
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{muted ? "Turn on camera" : "Turn off camera"}
</Tooltip>
)}
{() => (muted ? "Turn on camera" : "Turn off camera")}
</TooltipTrigger>
);
}
@ -115,11 +104,7 @@ export function ScreenshareButton({ enabled, className, ...rest }) {
<Button variant="toolbar" {...rest} on={enabled}>
<ScreenshareIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{enabled ? "Stop sharing screen" : "Share screen"}
</Tooltip>
)}
{() => (enabled ? "Stop sharing screen" : "Share screen")}
</TooltipTrigger>
);
}
@ -134,11 +119,7 @@ export function HangupButton({ className, ...rest }) {
>
<HangupIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
Leave
</Tooltip>
)}
{() => "Leave"}
</TooltipTrigger>
);
}

View File

@ -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;

View File

@ -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 (
<>
<div className={styles.callList}>
@ -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 (
<div className={styles.callTile}>
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
@ -41,7 +49,7 @@ function CallTile({ name, avatarUrl, roomId, participants, client }) {
size="lg"
bgKey={name}
src={avatarUrl}
fallback={<VideoIcon width={16} height={16} />}
fallback={name.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
<div className={styles.callInfo}>
@ -49,7 +57,7 @@ function CallTile({ name, avatarUrl, roomId, participants, client }) {
{name}
</Body>
<Caption overflowEllipsis>{getRoomUrl(roomId)}</Caption>
{participants && (
{participants && !disableFacepile && (
<Facepile
className={styles.facePile}
client={client}

View File

@ -19,8 +19,11 @@ import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView";
import { usePageTitle } from "../usePageTitle";
export function HomePage() {
usePageTitle("Home");
const { isAuthenticated, isPasswordlessUser, loading, error, client } =
useClient();

View File

@ -107,7 +107,7 @@ export function RegisteredView({ client }) {
<Title className={styles.recentCallsTitle}>
Your recent Calls
</Title>
<CallList rooms={recentRooms} client={client} />
<CallList rooms={recentRooms} client={client} disableFacepile />
</>
)}
</main>

View File

@ -102,8 +102,8 @@ export function UnauthenticatedView() {
<InputField
id="userName"
name="userName"
label="Your name"
placeholder="Your name"
label="Username"
placeholder="Username"
type="text"
required
autoComplete="off"

View File

@ -27,6 +27,10 @@
width: 100%;
}
.selectTrigger:focus {
outline: auto;
}
.selectedItem {
white-space: nowrap;
overflow: hidden;

View File

@ -22,6 +22,10 @@ import App from "./App";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import { ErrorView } from "./FullScreenView";
import * as rageshake from "matrix-react-sdk/src/rageshake/rageshake";
import { InspectorContextProvider } from "./room/GroupCallInspector";
rageshake.init();
if (import.meta.env.VITE_CUSTOM_THEME) {
const style = document.documentElement.style;
@ -59,7 +63,9 @@ Sentry.init({
ReactDOM.render(
<React.StrictMode>
<Sentry.ErrorBoundary fallback={ErrorView}>
<App history={history} />
<InspectorContextProvider>
<App history={history} />
</InspectorContextProvider>
</Sentry.ErrorBoundary>
</React.StrictMode>,
document.getElementById("root")

View File

@ -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 (
<div className={classNames(styles.popoverMenuTrigger, className)}>
<popoverTrigger.type
{...mergeProps(popoverTrigger.props, menuTriggerProps)}
on={!disableOnState && popoverMenuState.isOpen}
ref={buttonRef}
/>
{popoverMenuState.isOpen && (
<OverlayContainer>
<Popover
{...overlayProps}
isOpen={popoverMenuState.isOpen}
onClose={popoverMenuState.close}
ref={popoverRef}
>
{popoverMenu({
...menuProps,
autoFocus: popoverMenuState.focusStrategy,
onClose: popoverMenuState.close,
})}
</Popover>
</OverlayContainer>
)}
</div>
);
}
const [popoverTrigger, popoverMenu] = children;
return (
<div className={classNames(styles.popoverMenuTrigger, className)}>
<popoverTrigger.type
{...popoverTrigger.props}
{...menuTriggerProps}
on={!disableOnState && popoverMenuState.isOpen}
ref={buttonRef}
/>
{popoverMenuState.isOpen && (
<OverlayContainer>
<Popover
{...overlayProps}
isOpen={popoverMenuState.isOpen}
onClose={popoverMenuState.close}
ref={popoverRef}
>
{popoverMenu({
...menuProps,
autoFocus: popoverMenuState.focusStrategy,
onClose: popoverMenuState.close,
})}
</Popover>
</OverlayContainer>
)}
</div>
);
}
);

View File

@ -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({
<Modal title="Profile" isDismissable {...rest}>
<ModalContent>
<form onSubmit={onSubmit}>
<FieldRow>
<InputField
id="userId"
name="userId"
label="User Id"
type="text"
disabled
value={client.getUserId()}
/>
</FieldRow>
<FieldRow>
<InputField
id="displayName"
@ -65,7 +75,7 @@ export function ProfileModal({
onChange={onChangeDisplayName}
/>
</FieldRow>
{isAuthenticated && !isPasswordlessUser && (
{isAuthenticated && (
<FieldRow>
<InputField
type="file"

View File

@ -46,7 +46,6 @@ limitations under the License.
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
@ -70,4 +69,8 @@ limitations under the License.
.container {
min-height: calc(100% - 76px);
}
.main {
justify-content: center;
}
}

View File

@ -16,11 +16,7 @@ export function GridLayoutMenu({ layout, setLayout }) {
<Button variant="icon">
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button>
{(props) => (
<Tooltip position="bottom" {...props}>
Layout Type
</Tooltip>
)}
{() => "Layout Type"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="Grid layout menu" onAction={setLayout}>

View File

@ -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 (
<InspectorContext.Provider value={context}>
{children}
</InspectorContext.Provider>
);
}
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 (
<div className={styles.scrollContainer}>
<div className={styles.sequenceDiagramViewer}>
<SelectInput
className={styles.selectInput}
label="Remote User"
selectedKey={selectedUserId}
onSelectionChange={onSelectUserId}
>
{remoteUserIds.map((userId) => (
<Item key={userId}>{userId}</Item>
))}
</SelectInput>
<div id="mermaid" />
<div ref={mermaidElRef} />
</div>
</div>
);
}
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 (
<Resizable enable={{ top: true }} defaultSize={{ height: 200 }}>
<ReactJson
theme="monokai"
src={{
...state,
roomStateEvents,
toDeviceEvents,
toDeviceEventsByCall,
sentVoipEvents,
sentVoipEventsByCall,
}}
name={null}
indentWidth={2}
collapsed={1}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
style={{ height: "100%", overflowY: "scroll" }}
/>
<Resizable
enable={{ top: true }}
defaultSize={{ height: 200 }}
className={styles.inspector}
>
<div className={styles.toolbar}>
<button onClick={() => setCurrentTab("sequence-diagrams")}>
Sequence Diagrams
</button>
<button onClick={() => setCurrentTab("inspector")}>Inspector</button>
</div>
{currentTab === "sequence-diagrams" && (
<SequenceDiagramViewer
localUserId={state.localUserId}
selectedUserId={selectedUserId}
onSelectUserId={setSelectedUserId}
remoteUserIds={state.remoteUserIds}
events={state.eventsByUserId[selectedUserId]}
/>
)}
{currentTab === "inspector" && (
<ReactJson
theme="monokai"
src={state}
name={null}
indentWidth={2}
shouldCollapse={shouldCollapse}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard
style={{ height: "100%", overflowY: "scroll" }}
/>
)}
</Resizable>
);
}

View File

@ -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;
}

View File

@ -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 (
<FullScreenView>

View File

@ -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}
/>

View File

@ -128,7 +128,7 @@ export function InCallView({
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenuContainer disableLogout />
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
{items.length === 0 ? (

View File

@ -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({
</Header>
<div className={styles.joinRoom}>
<div className={styles.joinRoomContent}>
<div className={styles.preview}>
<div className={styles.preview} ref={previewRef}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
@ -59,6 +66,20 @@ export function LobbyView({
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
{localVideoMuted && (
<div className={styles.avatarContainer}>
<Avatar
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize,
fontSize: Math.round(avatarSize / 2),
}}
src={avatarUrl && getAvatarUrl(client, avatarUrl, 96)}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
</div>
)}
<Button
className={styles.joinCallButton}
disabled={state !== GroupCallState.LocalCallFeedInitialized}

View File

@ -53,15 +53,28 @@ limitations under the License.
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
margin: 40px 20px 20px 20px;
margin: 20px;
}
.preview video {
width: 100%;
width: calc(100% + 1px);
height: 100%;
object-fit: contain;
background-color: black;
transform: scaleX(-1);
/* transform scale doesn't perfectly match width, so make -1.01 border issues */
transform: scaleX(-1.01);
}
.avatarContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--bgColor3);
}
.webcamPermissions {
@ -108,3 +121,9 @@ limitations under the License.
.previewButtons > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
}
}

View File

@ -38,15 +38,11 @@ export function OverflowMenu({
return (
<>
<PopoverMenuTrigger disableOnState>
<TooltipTrigger>
<TooltipTrigger placement="top">
<Button variant="toolbar">
<OverflowIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
More
</Tooltip>
)}
{() => "More"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="More menu" onAction={onAction}>

View File

@ -49,7 +49,7 @@ export function RoomAuthView() {
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenuContainer disableLogout />
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
<div className={styles.container}>
@ -60,15 +60,15 @@ export function RoomAuthView() {
<InputField
id="userName"
name="userName"
label="Your name"
placeholder="Your name"
label="Username"
placeholder="Username"
type="text"
required
autoComplete="off"
/>
</FieldRow>
<Caption>
By clicking "Go", you agree to our{" "}
By clicking "Join call now", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Caption>
{error && (

View File

@ -1,14 +1,17 @@
import React from "react";
import React, { useState } from "react";
import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css";
import { TabContainer, TabItem } from "../Tabs";
import { TabContainer, TabItem } from "../tabs/Tabs";
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler";
import { FieldRow, InputField } from "../input/Input";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { useSubmitRageshake } from "./useSubmitRageshake";
import { Subtitle } from "../typography/Typography";
export function SettingsModal({
client,
@ -25,6 +28,11 @@ export function SettingsModal({
setVideoInput,
} = useMediaHandler(client);
const [description, setDescription] = useState("");
const { submitRageshake, sending, sent, error, downloadDebugLog } =
useSubmitRageshake();
return (
<Modal
title="Settings"
@ -88,6 +96,34 @@ export function SettingsModal({
onChange={(e) => setShowInspector(e.target.checked)}
/>
</FieldRow>
<Subtitle>Feedback</Subtitle>
<FieldRow>
<InputField
id="description"
name="description"
label="Description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</FieldRow>
<FieldRow>
<Button onPress={() => submitRageshake({ description })}>
{sent
? "Debug Logs Sent"
: sending
? "Sending Debug Logs..."
: "Send Debug Logs"}
</Button>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow>
<Button onPress={downloadDebugLog}>Download Debug Logs</Button>
</FieldRow>
</TabItem>
</TabContainer>
</Modal>

View File

@ -0,0 +1,228 @@
import { useCallback, useContext, useState } from "react";
import * as rageshake from "matrix-react-sdk/src/rageshake/rageshake";
import pako from "pako";
import { useClient } from "../ClientContext";
import { InspectorContext } from "../room/GroupCallInspector";
export function useSubmitRageshake() {
const { client } = useClient();
const [{ json }] = useContext(InspectorContext);
const [{ sending, sent, error }, setState] = useState({
sending: false,
sent: false,
error: null,
});
const submitRageshake = useCallback(
async (opts) => {
if (sending) {
return;
}
try {
setState({ sending: true, sent: false, error: null });
let userAgent = "UNKNOWN";
if (window.navigator && window.navigator.userAgent) {
userAgent = window.navigator.userAgent;
}
let touchInput = "UNKNOWN";
try {
// MDN claims broad support across browsers
touchInput = String(window.matchMedia("(pointer: coarse)").matches);
} catch (e) {}
const body = new FormData();
body.append(
"text",
opts.description || "User did not supply any additional text."
);
body.append("app", "matrix-video-chat");
body.append("version", "dev");
body.append("user_agent", userAgent);
body.append("installed_pwa", false);
body.append("touch_input", touchInput);
if (client) {
body.append("user_id", client.credentials.userId);
body.append("device_id", client.deviceId);
if (client.isCryptoEnabled()) {
const keys = [`ed25519:${client.getDeviceEd25519Key()}`];
if (client.getDeviceCurve25519Key) {
keys.push(`curve25519:${client.getDeviceCurve25519Key()}`);
}
body.append("device_keys", keys.join(", "));
body.append("cross_signing_key", client.getCrossSigningId());
// add cross-signing status information
const crossSigning = client.crypto.crossSigningInfo;
const secretStorage = client.crypto.secretStorage;
body.append(
"cross_signing_ready",
String(await client.isCrossSigningReady())
);
body.append(
"cross_signing_supported_by_hs",
String(
await client.doesServerSupportUnstableFeature(
"org.matrix.e2e_cross_signing"
)
)
);
body.append("cross_signing_key", crossSigning.getId());
body.append(
"cross_signing_privkey_in_secret_storage",
String(
!!(await crossSigning.isStoredInSecretStorage(secretStorage))
)
);
const pkCache = client.getCrossSigningCacheCallbacks();
body.append(
"cross_signing_master_privkey_cached",
String(
!!(pkCache && (await pkCache.getCrossSigningKeyCache("master")))
)
);
body.append(
"cross_signing_self_signing_privkey_cached",
String(
!!(
pkCache &&
(await pkCache.getCrossSigningKeyCache("self_signing"))
)
)
);
body.append(
"cross_signing_user_signing_privkey_cached",
String(
!!(
pkCache &&
(await pkCache.getCrossSigningKeyCache("user_signing"))
)
)
);
body.append(
"secret_storage_ready",
String(await client.isSecretStorageReady())
);
body.append(
"secret_storage_key_in_account",
String(!!(await secretStorage.hasKey()))
);
body.append(
"session_backup_key_in_secret_storage",
String(!!(await client.isKeyBackupKeyStored()))
);
const sessionBackupKeyFromCache =
await client.crypto.getSessionBackupPrivateKey();
body.append(
"session_backup_key_cached",
String(!!sessionBackupKeyFromCache)
);
body.append(
"session_backup_key_well_formed",
String(sessionBackupKeyFromCache instanceof Uint8Array)
);
}
}
if (opts.label) {
body.append("label", opts.label);
}
// add storage persistence/quota information
if (navigator.storage && navigator.storage.persisted) {
try {
body.append(
"storageManager_persisted",
String(await navigator.storage.persisted())
);
} catch (e) {}
} else if (document.hasStorageAccess) {
// Safari
try {
body.append(
"storageManager_persisted",
String(await document.hasStorageAccess())
);
} catch (e) {}
}
if (navigator.storage && navigator.storage.estimate) {
try {
const estimate = await navigator.storage.estimate();
body.append("storageManager_quota", String(estimate.quota));
body.append("storageManager_usage", String(estimate.usage));
if (estimate.usageDetails) {
Object.keys(estimate.usageDetails).forEach((k) => {
body.append(
`storageManager_usage_${k}`,
String(estimate.usageDetails[k])
);
});
}
} catch (e) {}
}
const logs = await rageshake.getLogsForReport();
for (const entry of logs) {
// encode as UTF-8
let buf = new TextEncoder().encode(entry.lines);
// compress
buf = pako.gzip(buf);
body.append("compressed-log", new Blob([buf]), entry.id);
}
if (json) {
body.append(
"file",
new Blob([JSON.stringify(json)], { type: "text/plain" }),
"groupcall.txt"
);
}
await fetch(
import.meta.env.VITE_RAGESHAKE_SUBMIT_URL ||
"https://element.io/bugreports/submit",
{
method: "POST",
body,
}
);
setState({ sending: false, sent: true, error: null });
} catch (error) {
setState({ sending: false, sent: false, error });
console.error(error);
}
},
[client]
);
const downloadDebugLog = useCallback(() => {
const blob = new Blob([JSON.stringify(json)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const el = document.createElement("a");
el.href = url;
el.download = "groupcall.json";
el.style.display = "none";
document.body.appendChild(el);
el.click();
setTimeout(() => {
URL.revokeObjectURL(url);
el.parentNode.removeChild(el);
}, 0);
});
return { submitRageshake, sending, sent, error, downloadDebugLog };
}

View File

@ -1,15 +1,14 @@
.tabContainer {
width: 100%;
display: flex;
flex: 1;
flex-direction: column;
}
.tabList {
display: flex;
flex-direction: column;
list-style: none;
padding: 0;
margin: 0;
margin: 0 auto 24px auto;
}
.tab {
@ -20,14 +19,14 @@
background-color: transparent;
display: flex;
align-items: center;
padding: 0 16px;
padding: 0 8px;
border: none;
cursor: pointer;
}
.tab > * {
color: var(--textColor4);
margin-right: 16px;
margin: 0 8px 0 0;
}
.tab svg * {
@ -57,6 +56,31 @@
display: flex;
flex-direction: column;
flex: 1;
padding: 0 40px;
padding: 0;
overflow-y: auto;
}
@media (min-width: 800px) {
.tab {
padding: 0 16px;
}
.tab > * {
margin: 0 16px 0 0;
}
.tabContainer {
width: 100%;
flex-direction: row;
margin: 27px 16px;
}
.tabList {
flex-direction: column;
margin-bottom: 0;
}
.tabPanel {
padding: 0 40px;
}
}

49
src/tabs/Tabs.stories.jsx Normal file
View File

@ -0,0 +1,49 @@
import React from "react";
import { TabContainer, TabItem } from "./Tabs";
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { Body } from "../typography/Typography";
export default {
title: "Tabs",
component: TabContainer,
parameters: {
layout: "fullscreen",
},
};
export const Tabs = () => (
<TabContainer>
<TabItem
title={
<>
<AudioIcon width={16} height={16} />
<Body>Audio</Body>
</>
}
>
Audio Tab Content
</TabItem>
<TabItem
title={
<>
<VideoIcon width={16} height={16} />
<Body>Video</Body>
</>
}
>
Video Tab Content
</TabItem>
<TabItem
title={
<>
<DeveloperIcon width={16} height={16} />
<Body>Developer</Body>
</>
}
>
Developer Tab Content
</TabItem>
</TabContainer>
);

View File

@ -1,3 +1,3 @@
.hideFocus * {
outline: none;
outline: none !important;
}

9
src/usePageTitle.js Normal file
View File

@ -0,0 +1,9 @@
import { useEffect } from "react";
export function usePageTitle(title) {
useEffect(() => {
document.title = title
? `${import.meta.env.VITE_PRODUCT_NAME} | ${title}`
: import.meta.env.VITE_PRODUCT_NAME;
}, [title]);
}

View File

@ -1,17 +0,0 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2016",
"noImplicitAny": false,
"sourceMap": false,
"declaration": true,
"noEmit": true,
"jsx": "react",
"lib": ["es2019", "dom", "dom.iterable"]
}
}

View File

@ -16,6 +16,7 @@ limitations under the License.
import { defineConfig, loadEnv } from "vite";
import svgrPlugin from "vite-plugin-svgr";
import { createHtmlPlugin } from "vite-plugin-html";
import path from "path";
// https://vitejs.dev/config/
@ -23,17 +24,37 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [svgrPlugin()],
plugins: [
svgrPlugin(),
createHtmlPlugin({
inject: {
data: {
title: env.VITE_PRODUCT_NAME || "Matrix Video Chat",
},
},
}),
],
server: {
proxy: {
"/_matrix": env.VITE_DEFAULT_HOMESERVER || "http://localhost:8008",
},
fs: {
// Current we're bundling files linked in from matrix-react-sdk
// We should re-enable this if we plan to run Vite outside the dev server mode
strict: false,
},
},
resolve: {
alias: {
"$(res)": path.resolve(__dirname, "node_modules/matrix-react-sdk/res"),
},
dedupe: ["react", "react-dom", "matrix-js-sdk"],
dedupe: [
"react",
"react-dom",
"matrix-js-sdk",
"react-use-measure",
"@juggle/resize-observer",
],
},
};
});

4911
yarn.lock

File diff suppressed because it is too large Load Diff