Merge pull request #1037 from vector-im/SimonBrandner/feat/settings
Settings improvements
This commit is contained in:
		
				commit
				
					
						e67290550c
					
				
			
		
					 24 changed files with 445 additions and 514 deletions
				
			
		| 
						 | 
					@ -38,7 +38,6 @@
 | 
				
			||||||
  "Create account": "Create account",
 | 
					  "Create account": "Create account",
 | 
				
			||||||
  "Debug log": "Debug log",
 | 
					  "Debug log": "Debug log",
 | 
				
			||||||
  "Debug log request": "Debug log request",
 | 
					  "Debug log request": "Debug log request",
 | 
				
			||||||
  "Description (optional)": "Description (optional)",
 | 
					 | 
				
			||||||
  "Details": "Details",
 | 
					  "Details": "Details",
 | 
				
			||||||
  "Developer": "Developer",
 | 
					  "Developer": "Developer",
 | 
				
			||||||
  "Developer Settings": "Developer Settings",
 | 
					  "Developer Settings": "Developer Settings",
 | 
				
			||||||
| 
						 | 
					@ -47,13 +46,14 @@
 | 
				
			||||||
  "Element Call Home": "Element Call Home",
 | 
					  "Element Call Home": "Element Call Home",
 | 
				
			||||||
  "Exit full screen": "Exit full screen",
 | 
					  "Exit full screen": "Exit full screen",
 | 
				
			||||||
  "Expose developer settings in the settings window.": "Expose developer settings in the settings window.",
 | 
					  "Expose developer settings in the settings window.": "Expose developer settings in the settings window.",
 | 
				
			||||||
 | 
					  "Feedback": "Feedback",
 | 
				
			||||||
  "Fetching group call timed out.": "Fetching group call timed out.",
 | 
					  "Fetching group call timed out.": "Fetching group call timed out.",
 | 
				
			||||||
  "Freedom": "Freedom",
 | 
					  "Freedom": "Freedom",
 | 
				
			||||||
  "Full screen": "Full screen",
 | 
					  "Full screen": "Full screen",
 | 
				
			||||||
  "Go": "Go",
 | 
					  "Go": "Go",
 | 
				
			||||||
  "Grid layout menu": "Grid layout menu",
 | 
					  "Grid layout menu": "Grid layout menu",
 | 
				
			||||||
  "Having trouble? Help us fix it.": "Having trouble? Help us fix it.",
 | 
					 | 
				
			||||||
  "Home": "Home",
 | 
					  "Home": "Home",
 | 
				
			||||||
 | 
					  "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
 | 
				
			||||||
  "Include debug logs": "Include debug logs",
 | 
					  "Include debug logs": "Include debug logs",
 | 
				
			||||||
  "Incompatible versions": "Incompatible versions",
 | 
					  "Incompatible versions": "Incompatible versions",
 | 
				
			||||||
  "Incompatible versions!": "Incompatible versions!",
 | 
					  "Incompatible versions!": "Incompatible versions!",
 | 
				
			||||||
| 
						 | 
					@ -73,7 +73,6 @@
 | 
				
			||||||
  "Microphone {{n}}": "Microphone {{n}}",
 | 
					  "Microphone {{n}}": "Microphone {{n}}",
 | 
				
			||||||
  "Microphone permissions needed to join the call.": "Microphone permissions needed to join the call.",
 | 
					  "Microphone permissions needed to join the call.": "Microphone permissions needed to join the call.",
 | 
				
			||||||
  "More": "More",
 | 
					  "More": "More",
 | 
				
			||||||
  "More menu": "More menu",
 | 
					 | 
				
			||||||
  "Mute microphone": "Mute microphone",
 | 
					  "Mute microphone": "Mute microphone",
 | 
				
			||||||
  "No": "No",
 | 
					  "No": "No",
 | 
				
			||||||
  "Not now, return to home screen": "Not now, return to home screen",
 | 
					  "Not now, return to home screen": "Not now, return to home screen",
 | 
				
			||||||
| 
						 | 
					@ -94,8 +93,6 @@
 | 
				
			||||||
  "Release to stop": "Release to stop",
 | 
					  "Release to stop": "Release to stop",
 | 
				
			||||||
  "Remove": "Remove",
 | 
					  "Remove": "Remove",
 | 
				
			||||||
  "Return to home screen": "Return to home screen",
 | 
					  "Return to home screen": "Return to home screen",
 | 
				
			||||||
  "Save": "Save",
 | 
					 | 
				
			||||||
  "Saving…": "Saving…",
 | 
					 | 
				
			||||||
  "Select an option": "Select an option",
 | 
					  "Select an option": "Select an option",
 | 
				
			||||||
  "Send debug logs": "Send debug logs",
 | 
					  "Send debug logs": "Send debug logs",
 | 
				
			||||||
  "Sending debug logs…": "Sending debug logs…",
 | 
					  "Sending debug logs…": "Sending debug logs…",
 | 
				
			||||||
| 
						 | 
					@ -110,11 +107,13 @@
 | 
				
			||||||
  "Speaker {{n}}": "Speaker {{n}}",
 | 
					  "Speaker {{n}}": "Speaker {{n}}",
 | 
				
			||||||
  "Spotlight": "Spotlight",
 | 
					  "Spotlight": "Spotlight",
 | 
				
			||||||
  "Stop sharing screen": "Stop sharing screen",
 | 
					  "Stop sharing screen": "Stop sharing screen",
 | 
				
			||||||
 | 
					  "Submit": "Submit",
 | 
				
			||||||
  "Submit feedback": "Submit feedback",
 | 
					  "Submit feedback": "Submit feedback",
 | 
				
			||||||
  "Submitting feedback…": "Submitting feedback…",
 | 
					  "Submitting…": "Submitting…",
 | 
				
			||||||
  "Take me Home": "Take me Home",
 | 
					  "Take me Home": "Take me Home",
 | 
				
			||||||
  "Talk over speaker": "Talk over speaker",
 | 
					  "Talk over speaker": "Talk over speaker",
 | 
				
			||||||
  "Talking…": "Talking…",
 | 
					  "Talking…": "Talking…",
 | 
				
			||||||
 | 
					  "Thanks, we received your feedback!": "Thanks, we received your feedback!",
 | 
				
			||||||
  "Thanks! We'll get right on it.": "Thanks! We'll get right on it.",
 | 
					  "Thanks! We'll get right on it.": "Thanks! We'll get right on it.",
 | 
				
			||||||
  "This call already exists, would you like to join?": "This call already exists, would you like to join?",
 | 
					  "This call already exists, would you like to join?": "This call already exists, would you like to join?",
 | 
				
			||||||
  "This feature is only supported on Firefox.": "This feature is only supported on Firefox.",
 | 
					  "This feature is only supported on Firefox.": "This feature is only supported on Firefox.",
 | 
				
			||||||
| 
						 | 
					@ -124,7 +123,6 @@
 | 
				
			||||||
  "Turn on camera": "Turn on camera",
 | 
					  "Turn on camera": "Turn on camera",
 | 
				
			||||||
  "Unmute microphone": "Unmute microphone",
 | 
					  "Unmute microphone": "Unmute microphone",
 | 
				
			||||||
  "Use the upcoming grid system": "Use the upcoming grid system",
 | 
					  "Use the upcoming grid system": "Use the upcoming grid system",
 | 
				
			||||||
  "User ID": "User ID",
 | 
					 | 
				
			||||||
  "User menu": "User menu",
 | 
					  "User menu": "User menu",
 | 
				
			||||||
  "Username": "Username",
 | 
					  "Username": "Username",
 | 
				
			||||||
  "Version: {{version}}": "Version: {{version}}",
 | 
					  "Version: {{version}}": "Version: {{version}}",
 | 
				
			||||||
| 
						 | 
					@ -138,5 +136,6 @@
 | 
				
			||||||
  "WebRTC is not supported or is being blocked in this browser.": "WebRTC is not supported or is being blocked in this browser.",
 | 
					  "WebRTC is not supported or is being blocked in this browser.": "WebRTC is not supported or is being blocked in this browser.",
 | 
				
			||||||
  "Yes, join call": "Yes, join call",
 | 
					  "Yes, join call": "Yes, join call",
 | 
				
			||||||
  "You can't talk at the same time": "You can't talk at the same time",
 | 
					  "You can't talk at the same time": "You can't talk at the same time",
 | 
				
			||||||
 | 
					  "Your feedback": "Your feedback",
 | 
				
			||||||
  "Your recent calls": "Your recent calls"
 | 
					  "Your recent calls": "Your recent calls"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2021 New Vector Ltd
 | 
					Copyright 2021 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -30,6 +30,7 @@ import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
 | 
				
			||||||
import { InspectorContextProvider } from "./room/GroupCallInspector";
 | 
					import { InspectorContextProvider } from "./room/GroupCallInspector";
 | 
				
			||||||
import { CrashView, LoadingView } from "./FullScreenView";
 | 
					import { CrashView, LoadingView } from "./FullScreenView";
 | 
				
			||||||
import { Initializer } from "./initializer";
 | 
					import { Initializer } from "./initializer";
 | 
				
			||||||
 | 
					import { MediaHandlerProvider } from "./settings/useMediaHandler";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const SentryRoute = Sentry.withSentryRouting(Route);
 | 
					const SentryRoute = Sentry.withSentryRouting(Route);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -55,6 +56,7 @@ export default function App({ history }: AppProps) {
 | 
				
			||||||
      {loaded ? (
 | 
					      {loaded ? (
 | 
				
			||||||
        <Suspense fallback={null}>
 | 
					        <Suspense fallback={null}>
 | 
				
			||||||
          <ClientProvider>
 | 
					          <ClientProvider>
 | 
				
			||||||
 | 
					            <MediaHandlerProvider>
 | 
				
			||||||
              <InspectorContextProvider>
 | 
					              <InspectorContextProvider>
 | 
				
			||||||
                <Sentry.ErrorBoundary fallback={errorPage}>
 | 
					                <Sentry.ErrorBoundary fallback={errorPage}>
 | 
				
			||||||
                  <OverlayProvider>
 | 
					                  <OverlayProvider>
 | 
				
			||||||
| 
						 | 
					@ -81,6 +83,7 @@ export default function App({ history }: AppProps) {
 | 
				
			||||||
                  </OverlayProvider>
 | 
					                  </OverlayProvider>
 | 
				
			||||||
                </Sentry.ErrorBoundary>
 | 
					                </Sentry.ErrorBoundary>
 | 
				
			||||||
              </InspectorContextProvider>
 | 
					              </InspectorContextProvider>
 | 
				
			||||||
 | 
					            </MediaHandlerProvider>
 | 
				
			||||||
          </ClientProvider>
 | 
					          </ClientProvider>
 | 
				
			||||||
        </Suspense>
 | 
					        </Suspense>
 | 
				
			||||||
      ) : (
 | 
					      ) : (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -40,7 +40,7 @@ limitations under the License.
 | 
				
			||||||
.modalHeader {
 | 
					.modalHeader {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  justify-content: space-between;
 | 
					  justify-content: space-between;
 | 
				
			||||||
  padding: 34px 34px 0 34px;
 | 
					  padding: 34px 32px 0 32px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.modalHeader h3 {
 | 
					.modalHeader h3 {
 | 
				
			||||||
| 
						 | 
					@ -72,7 +72,7 @@ limitations under the License.
 | 
				
			||||||
  .modalHeader {
 | 
					  .modalHeader {
 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
    justify-content: space-between;
 | 
					    justify-content: space-between;
 | 
				
			||||||
    padding: 24px 24px 0 24px;
 | 
					    padding: 32px 20px 0 20px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .modal.mobileFullScreen {
 | 
					  .modal.mobileFullScreen {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -25,6 +25,7 @@ import { Menu } from "./Menu";
 | 
				
			||||||
import { TooltipTrigger } from "./Tooltip";
 | 
					import { TooltipTrigger } from "./Tooltip";
 | 
				
			||||||
import { Avatar, Size } from "./Avatar";
 | 
					import { Avatar, Size } from "./Avatar";
 | 
				
			||||||
import { ReactComponent as UserIcon } from "./icons/User.svg";
 | 
					import { ReactComponent as UserIcon } from "./icons/User.svg";
 | 
				
			||||||
 | 
					import { ReactComponent as SettingsIcon } from "./icons/Settings.svg";
 | 
				
			||||||
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
 | 
					import { ReactComponent as LoginIcon } from "./icons/Login.svg";
 | 
				
			||||||
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
 | 
					import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
 | 
				
			||||||
import { Body } from "./typography/Typography";
 | 
					import { Body } from "./typography/Typography";
 | 
				
			||||||
| 
						 | 
					@ -60,6 +61,11 @@ export function UserMenu({
 | 
				
			||||||
        label: displayName,
 | 
					        label: displayName,
 | 
				
			||||||
        dataTestid: "usermenu_user",
 | 
					        dataTestid: "usermenu_user",
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					      arr.push({
 | 
				
			||||||
 | 
					        key: "settings",
 | 
				
			||||||
 | 
					        icon: SettingsIcon,
 | 
				
			||||||
 | 
					        label: t("Settings"),
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (isPasswordlessUser && !preventNavigation) {
 | 
					      if (isPasswordlessUser && !preventNavigation) {
 | 
				
			||||||
        arr.push({
 | 
					        arr.push({
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React, { useCallback } from "react";
 | 
					import React, { useCallback, useState } from "react";
 | 
				
			||||||
import { useHistory, useLocation } from "react-router-dom";
 | 
					import { useHistory, useLocation } from "react-router-dom";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { useClient } from "./ClientContext";
 | 
					import { useClient } from "./ClientContext";
 | 
				
			||||||
import { useProfile } from "./profile/useProfile";
 | 
					import { useProfile } from "./profile/useProfile";
 | 
				
			||||||
import { useModalTriggerState } from "./Modal";
 | 
					import { useModalTriggerState } from "./Modal";
 | 
				
			||||||
import { ProfileModal } from "./profile/ProfileModal";
 | 
					import { SettingsModal } from "./settings/SettingsModal";
 | 
				
			||||||
import { UserMenu } from "./UserMenu";
 | 
					import { UserMenu } from "./UserMenu";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
| 
						 | 
					@ -35,10 +35,17 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
 | 
				
			||||||
  const { displayName, avatarUrl } = useProfile(client);
 | 
					  const { displayName, avatarUrl } = useProfile(client);
 | 
				
			||||||
  const { modalState, modalProps } = useModalTriggerState();
 | 
					  const { modalState, modalProps } = useModalTriggerState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [defaultSettingsTab, setDefaultSettingsTab] = useState<string>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onAction = useCallback(
 | 
					  const onAction = useCallback(
 | 
				
			||||||
    (value: string) => {
 | 
					    async (value: string) => {
 | 
				
			||||||
      switch (value) {
 | 
					      switch (value) {
 | 
				
			||||||
        case "user":
 | 
					        case "user":
 | 
				
			||||||
 | 
					          setDefaultSettingsTab("profile");
 | 
				
			||||||
 | 
					          modalState.open();
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					        case "settings":
 | 
				
			||||||
 | 
					          setDefaultSettingsTab("audio");
 | 
				
			||||||
          modalState.open();
 | 
					          modalState.open();
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        case "logout":
 | 
					        case "logout":
 | 
				
			||||||
| 
						 | 
					@ -64,7 +71,13 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
 | 
				
			||||||
          displayName || (userName ? userName.replace("@", "") : undefined)
 | 
					          displayName || (userName ? userName.replace("@", "") : undefined)
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      {modalState.isOpen && <ProfileModal client={client} {...modalProps} />}
 | 
					      {modalState.isOpen && (
 | 
				
			||||||
 | 
					        <SettingsModal
 | 
				
			||||||
 | 
					          client={client}
 | 
				
			||||||
 | 
					          defaultTab={defaultSettingsTab}
 | 
				
			||||||
 | 
					          {...modalProps}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -39,10 +39,10 @@ limitations under the License.
 | 
				
			||||||
.secondaryHangup,
 | 
					.secondaryHangup,
 | 
				
			||||||
.button,
 | 
					.button,
 | 
				
			||||||
.copyButton {
 | 
					.copyButton {
 | 
				
			||||||
  padding: 7px 15px;
 | 
					  padding: 8px 20px;
 | 
				
			||||||
  border-radius: 8px;
 | 
					  border-radius: 8px;
 | 
				
			||||||
  font-size: var(--font-size-body);
 | 
					  font-size: var(--font-size-body);
 | 
				
			||||||
  font-weight: 700;
 | 
					  font-weight: 600;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.button {
 | 
					.button {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -238,7 +238,7 @@ export function SettingsButton({
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <TooltipTrigger tooltip={tooltip}>
 | 
					    <TooltipTrigger tooltip={tooltip}>
 | 
				
			||||||
      <Button variant="toolbar" {...rest}>
 | 
					      <Button variant="toolbar" {...rest}>
 | 
				
			||||||
        <SettingsIcon />
 | 
					        <SettingsIcon width={20} height={20} />
 | 
				
			||||||
      </Button>
 | 
					      </Button>
 | 
				
			||||||
    </TooltipTrigger>
 | 
					    </TooltipTrigger>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
| 
						 | 
					@ -246,9 +246,11 @@ export function SettingsButton({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function InviteButton({
 | 
					export function InviteButton({
 | 
				
			||||||
  className,
 | 
					  className,
 | 
				
			||||||
 | 
					  variant = "toolbar",
 | 
				
			||||||
  ...rest
 | 
					  ...rest
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  className?: string;
 | 
					  className?: string;
 | 
				
			||||||
 | 
					  variant?: string;
 | 
				
			||||||
  // TODO: add all props for <Button>
 | 
					  // TODO: add all props for <Button>
 | 
				
			||||||
  [index: string]: unknown;
 | 
					  [index: string]: unknown;
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
| 
						 | 
					@ -257,7 +259,7 @@ export function InviteButton({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <TooltipTrigger tooltip={tooltip}>
 | 
					    <TooltipTrigger tooltip={tooltip}>
 | 
				
			||||||
      <Button variant="toolbar" {...rest}>
 | 
					      <Button variant={variant} {...rest}>
 | 
				
			||||||
        <AddUserIcon />
 | 
					        <AddUserIcon />
 | 
				
			||||||
      </Button>
 | 
					      </Button>
 | 
				
			||||||
    </TooltipTrigger>
 | 
					    </TooltipTrigger>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -180,10 +180,16 @@ h2 {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Subtitle */
 | 
					/* Subtitle */
 | 
				
			||||||
h3 {
 | 
					h3 {
 | 
				
			||||||
  font-weight: 400;
 | 
					  font-weight: 600;
 | 
				
			||||||
  font-size: var(--font-size-subtitle);
 | 
					  font-size: var(--font-size-subtitle);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Body Semi Bold */
 | 
				
			||||||
 | 
					h4 {
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  font-size: var(--font-size-body);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
h1,
 | 
					h1,
 | 
				
			||||||
h2,
 | 
					h2,
 | 
				
			||||||
h3 {
 | 
					h3 {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -54,4 +54,6 @@ limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.removeButton {
 | 
					.removeButton {
 | 
				
			||||||
  color: var(--accent);
 | 
					  color: var(--accent);
 | 
				
			||||||
 | 
					  font-size: var(--font-size-caption);
 | 
				
			||||||
 | 
					  padding: 6px 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -72,6 +72,7 @@ interface InputFieldProps {
 | 
				
			||||||
  autoCorrect?: string;
 | 
					  autoCorrect?: string;
 | 
				
			||||||
  autoCapitalize?: string;
 | 
					  autoCapitalize?: string;
 | 
				
			||||||
  value?: string;
 | 
					  value?: string;
 | 
				
			||||||
 | 
					  defaultValue?: string;
 | 
				
			||||||
  placeholder?: string;
 | 
					  placeholder?: string;
 | 
				
			||||||
  defaultChecked?: boolean;
 | 
					  defaultChecked?: boolean;
 | 
				
			||||||
  onChange?: (event: ChangeEvent) => void;
 | 
					  onChange?: (event: ChangeEvent) => void;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -22,8 +22,6 @@ limitations under the License.
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.label {
 | 
					.label {
 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  font-size: var(--font-size-subtitle);
 | 
					 | 
				
			||||||
  margin-top: 0;
 | 
					  margin-top: 0;
 | 
				
			||||||
  margin-bottom: 12px;
 | 
					  margin-bottom: 12px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,146 +0,0 @@
 | 
				
			||||||
/*
 | 
					 | 
				
			||||||
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, { ChangeEvent, useCallback, useEffect, useState } from "react";
 | 
					 | 
				
			||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
					 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Button } from "../button";
 | 
					 | 
				
			||||||
import { useProfile } from "./useProfile";
 | 
					 | 
				
			||||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
 | 
					 | 
				
			||||||
import { Modal, ModalContent } from "../Modal";
 | 
					 | 
				
			||||||
import { AvatarInputField } from "../input/AvatarInputField";
 | 
					 | 
				
			||||||
import styles from "./ProfileModal.module.css";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface Props {
 | 
					 | 
				
			||||||
  client: MatrixClient;
 | 
					 | 
				
			||||||
  onClose: () => void;
 | 
					 | 
				
			||||||
  [rest: string]: unknown;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
export function ProfileModal({ client, ...rest }: Props) {
 | 
					 | 
				
			||||||
  const { onClose } = rest;
 | 
					 | 
				
			||||||
  const { t } = useTranslation();
 | 
					 | 
				
			||||||
  const {
 | 
					 | 
				
			||||||
    success,
 | 
					 | 
				
			||||||
    error,
 | 
					 | 
				
			||||||
    loading,
 | 
					 | 
				
			||||||
    displayName: initialDisplayName,
 | 
					 | 
				
			||||||
    avatarUrl,
 | 
					 | 
				
			||||||
    saveProfile,
 | 
					 | 
				
			||||||
  } = useProfile(client);
 | 
					 | 
				
			||||||
  const [displayName, setDisplayName] = useState(initialDisplayName || "");
 | 
					 | 
				
			||||||
  const [removeAvatar, setRemoveAvatar] = useState(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const onRemoveAvatar = useCallback(() => {
 | 
					 | 
				
			||||||
    setRemoveAvatar(true);
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const onChangeDisplayName = useCallback(
 | 
					 | 
				
			||||||
    (e: ChangeEvent<HTMLInputElement>) => {
 | 
					 | 
				
			||||||
      setDisplayName(e.target.value);
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    [setDisplayName]
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const onSubmit = useCallback(
 | 
					 | 
				
			||||||
    (e) => {
 | 
					 | 
				
			||||||
      e.preventDefault();
 | 
					 | 
				
			||||||
      const data = new FormData(e.target);
 | 
					 | 
				
			||||||
      const displayNameDataEntry = data.get("displayName");
 | 
					 | 
				
			||||||
      const avatar: File | string = data.get("avatar");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const avatarSize =
 | 
					 | 
				
			||||||
        typeof avatar == "string" ? avatar.length : avatar.size;
 | 
					 | 
				
			||||||
      const displayName =
 | 
					 | 
				
			||||||
        typeof displayNameDataEntry == "string"
 | 
					 | 
				
			||||||
          ? displayNameDataEntry
 | 
					 | 
				
			||||||
          : displayNameDataEntry.name;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      saveProfile({
 | 
					 | 
				
			||||||
        displayName,
 | 
					 | 
				
			||||||
        avatar: avatar && avatarSize > 0 ? avatar : undefined,
 | 
					 | 
				
			||||||
        removeAvatar: removeAvatar && (!avatar || avatarSize === 0),
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    [saveProfile, removeAvatar]
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    if (success) {
 | 
					 | 
				
			||||||
      onClose();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }, [success, onClose]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <Modal title={t("Profile")} isDismissable {...rest}>
 | 
					 | 
				
			||||||
      <ModalContent>
 | 
					 | 
				
			||||||
        <form onSubmit={onSubmit}>
 | 
					 | 
				
			||||||
          <FieldRow className={styles.avatarFieldRow}>
 | 
					 | 
				
			||||||
            <AvatarInputField
 | 
					 | 
				
			||||||
              id="avatar"
 | 
					 | 
				
			||||||
              name="avatar"
 | 
					 | 
				
			||||||
              label={t("Avatar")}
 | 
					 | 
				
			||||||
              avatarUrl={avatarUrl}
 | 
					 | 
				
			||||||
              displayName={displayName}
 | 
					 | 
				
			||||||
              onRemoveAvatar={onRemoveAvatar}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          </FieldRow>
 | 
					 | 
				
			||||||
          <FieldRow>
 | 
					 | 
				
			||||||
            <InputField
 | 
					 | 
				
			||||||
              id="userId"
 | 
					 | 
				
			||||||
              name="userId"
 | 
					 | 
				
			||||||
              label={t("User ID")}
 | 
					 | 
				
			||||||
              type="text"
 | 
					 | 
				
			||||||
              disabled
 | 
					 | 
				
			||||||
              value={client.getUserId()}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          </FieldRow>
 | 
					 | 
				
			||||||
          <FieldRow>
 | 
					 | 
				
			||||||
            <InputField
 | 
					 | 
				
			||||||
              id="displayName"
 | 
					 | 
				
			||||||
              name="displayName"
 | 
					 | 
				
			||||||
              label={t("Display name")}
 | 
					 | 
				
			||||||
              type="text"
 | 
					 | 
				
			||||||
              required
 | 
					 | 
				
			||||||
              autoComplete="off"
 | 
					 | 
				
			||||||
              placeholder={t("Display name")}
 | 
					 | 
				
			||||||
              value={displayName}
 | 
					 | 
				
			||||||
              onChange={onChangeDisplayName}
 | 
					 | 
				
			||||||
              data-testid="profile_displayname"
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          </FieldRow>
 | 
					 | 
				
			||||||
          {error && (
 | 
					 | 
				
			||||||
            <FieldRow>
 | 
					 | 
				
			||||||
              <ErrorMessage error={error} />
 | 
					 | 
				
			||||||
            </FieldRow>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          <FieldRow rightAlign>
 | 
					 | 
				
			||||||
            <Button type="button" variant="secondary" onPress={onClose}>
 | 
					 | 
				
			||||||
              Cancel
 | 
					 | 
				
			||||||
            </Button>
 | 
					 | 
				
			||||||
            <Button
 | 
					 | 
				
			||||||
              type="submit"
 | 
					 | 
				
			||||||
              disabled={loading}
 | 
					 | 
				
			||||||
              data-testid="profile_submit"
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              {loading ? t("Saving…") : t("Save")}
 | 
					 | 
				
			||||||
            </Button>
 | 
					 | 
				
			||||||
          </FieldRow>
 | 
					 | 
				
			||||||
        </form>
 | 
					 | 
				
			||||||
      </ModalContent>
 | 
					 | 
				
			||||||
    </Modal>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -24,6 +24,7 @@ import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
				
			||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
 | 
					import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
 | 
				
			||||||
import classNames from "classnames";
 | 
					import classNames from "classnames";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import { OverlayTriggerState } from "@react-stately/overlays";
 | 
				
			||||||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
 | 
					import { JoinRule } from "matrix-js-sdk/src/@types/partials";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import type { IWidgetApiRequest } from "matrix-widget-api";
 | 
					import type { IWidgetApiRequest } from "matrix-widget-api";
 | 
				
			||||||
| 
						 | 
					@ -33,6 +34,8 @@ import {
 | 
				
			||||||
  MicButton,
 | 
					  MicButton,
 | 
				
			||||||
  VideoButton,
 | 
					  VideoButton,
 | 
				
			||||||
  ScreenshareButton,
 | 
					  ScreenshareButton,
 | 
				
			||||||
 | 
					  SettingsButton,
 | 
				
			||||||
 | 
					  InviteButton,
 | 
				
			||||||
} from "../button";
 | 
					} from "../button";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Header,
 | 
					  Header,
 | 
				
			||||||
| 
						 | 
					@ -48,12 +51,8 @@ import {
 | 
				
			||||||
} from "../video-grid/VideoGrid";
 | 
					} from "../video-grid/VideoGrid";
 | 
				
			||||||
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
 | 
					import { VideoTileContainer } from "../video-grid/VideoTileContainer";
 | 
				
			||||||
import { GroupCallInspector } from "./GroupCallInspector";
 | 
					import { GroupCallInspector } from "./GroupCallInspector";
 | 
				
			||||||
import { OverflowMenu } from "./OverflowMenu";
 | 
					 | 
				
			||||||
import { GridLayoutMenu } from "./GridLayoutMenu";
 | 
					import { GridLayoutMenu } from "./GridLayoutMenu";
 | 
				
			||||||
import { Avatar } from "../Avatar";
 | 
					import { Avatar } from "../Avatar";
 | 
				
			||||||
import { UserMenuContainer } from "../UserMenuContainer";
 | 
					 | 
				
			||||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
 | 
					 | 
				
			||||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
 | 
					 | 
				
			||||||
import { useMediaHandler } from "../settings/useMediaHandler";
 | 
					import { useMediaHandler } from "../settings/useMediaHandler";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  useNewGrid,
 | 
					  useNewGrid,
 | 
				
			||||||
| 
						 | 
					@ -74,6 +73,10 @@ import { AudioSink } from "../video-grid/AudioSink";
 | 
				
			||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
 | 
					import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
 | 
				
			||||||
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
 | 
					import { NewVideoGrid } from "../video-grid/NewVideoGrid";
 | 
				
			||||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
 | 
					import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
 | 
				
			||||||
 | 
					import { SettingsModal } from "../settings/SettingsModal";
 | 
				
			||||||
 | 
					import { InviteModal } from "./InviteModal";
 | 
				
			||||||
 | 
					import { useRageshakeRequestModal } from "../settings/submit-rageshake";
 | 
				
			||||||
 | 
					import { RageshakeRequestModal } from "./RageshakeRequestModal";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
 | 
					const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
 | 
				
			||||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
 | 
					// There is currently a bug in Safari our our code with cloning and sending MediaStreams
 | 
				
			||||||
| 
						 | 
					@ -128,7 +131,6 @@ export function InCallView({
 | 
				
			||||||
}: Props) {
 | 
					}: Props) {
 | 
				
			||||||
  const { t } = useTranslation();
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
  usePreventScroll();
 | 
					  usePreventScroll();
 | 
				
			||||||
  const joinRule = useJoinRule(groupCall.room);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const containerRef1 = useRef<HTMLDivElement | null>(null);
 | 
					  const containerRef1 = useRef<HTMLDivElement | null>(null);
 | 
				
			||||||
  const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
 | 
					  const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
 | 
				
			||||||
| 
						 | 
					@ -151,11 +153,10 @@ export function InCallView({
 | 
				
			||||||
  const [audioContext, audioDestination] = useAudioContext();
 | 
					  const [audioContext, audioDestination] = useAudioContext();
 | 
				
			||||||
  const [showInspector] = useShowInspector();
 | 
					  const [showInspector] = useShowInspector();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
 | 
					 | 
				
			||||||
    useModalTriggerState();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const { hideScreensharing } = useUrlParams();
 | 
					  const { hideScreensharing } = useUrlParams();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const joinRule = useJoinRule(groupCall.room);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useCallViewKeyboardShortcuts(
 | 
					  useCallViewKeyboardShortcuts(
 | 
				
			||||||
    containerRef1,
 | 
					    containerRef1,
 | 
				
			||||||
    toggleMicrophoneMuted,
 | 
					    toggleMicrophoneMuted,
 | 
				
			||||||
| 
						 | 
					@ -346,6 +347,36 @@ export function InCallView({
 | 
				
			||||||
    modalProps: rageshakeRequestModalProps,
 | 
					    modalProps: rageshakeRequestModalProps,
 | 
				
			||||||
  } = useRageshakeRequestModal(groupCall.room.roomId);
 | 
					  } = useRageshakeRequestModal(groupCall.room.roomId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const {
 | 
				
			||||||
 | 
					    modalState: settingsModalState,
 | 
				
			||||||
 | 
					    modalProps: settingsModalProps,
 | 
				
			||||||
 | 
					  }: {
 | 
				
			||||||
 | 
					    modalState: OverlayTriggerState;
 | 
				
			||||||
 | 
					    modalProps: {
 | 
				
			||||||
 | 
					      isOpen: boolean;
 | 
				
			||||||
 | 
					      onClose: () => void;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  } = useModalTriggerState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const openSettings = useCallback(() => {
 | 
				
			||||||
 | 
					    settingsModalState.open();
 | 
				
			||||||
 | 
					  }, [settingsModalState]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const {
 | 
				
			||||||
 | 
					    modalState: inviteModalState,
 | 
				
			||||||
 | 
					    modalProps: inviteModalProps,
 | 
				
			||||||
 | 
					  }: {
 | 
				
			||||||
 | 
					    modalState: OverlayTriggerState;
 | 
				
			||||||
 | 
					    modalProps: {
 | 
				
			||||||
 | 
					      isOpen: boolean;
 | 
				
			||||||
 | 
					      onClose: () => void;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  } = useModalTriggerState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const openInvite = useCallback(() => {
 | 
				
			||||||
 | 
					    inviteModalState.open();
 | 
				
			||||||
 | 
					  }, [inviteModalState]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const containerClasses = classNames(styles.inRoom, {
 | 
					  const containerClasses = classNames(styles.inRoom, {
 | 
				
			||||||
    [styles.maximised]: maximisedParticipant,
 | 
					    [styles.maximised]: maximisedParticipant,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
| 
						 | 
					@ -405,17 +436,7 @@ export function InCallView({
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      if (!maximisedParticipant) {
 | 
					      if (!maximisedParticipant) {
 | 
				
			||||||
        buttons.push(
 | 
					        buttons.push(<SettingsButton key="4" onPress={openSettings} />);
 | 
				
			||||||
          <OverflowMenu
 | 
					 | 
				
			||||||
            key="4"
 | 
					 | 
				
			||||||
            inCall
 | 
					 | 
				
			||||||
            roomIdOrAlias={roomIdOrAlias}
 | 
					 | 
				
			||||||
            groupCall={groupCall}
 | 
					 | 
				
			||||||
            showInvite={joinRule === JoinRule.Public}
 | 
					 | 
				
			||||||
            feedbackModalState={feedbackModalState}
 | 
					 | 
				
			||||||
            feedbackModalProps={feedbackModalProps}
 | 
					 | 
				
			||||||
          />
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -439,7 +460,9 @@ export function InCallView({
 | 
				
			||||||
          </LeftNav>
 | 
					          </LeftNav>
 | 
				
			||||||
          <RightNav>
 | 
					          <RightNav>
 | 
				
			||||||
            <GridLayoutMenu layout={layout} setLayout={setLayout} />
 | 
					            <GridLayoutMenu layout={layout} setLayout={setLayout} />
 | 
				
			||||||
            <UserMenuContainer preventNavigation />
 | 
					            {joinRule === JoinRule.Public && (
 | 
				
			||||||
 | 
					              <InviteButton variant="icon" onClick={openInvite} />
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
          </RightNav>
 | 
					          </RightNav>
 | 
				
			||||||
        </Header>
 | 
					        </Header>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
| 
						 | 
					@ -459,6 +482,16 @@ export function InCallView({
 | 
				
			||||||
          roomIdOrAlias={roomIdOrAlias}
 | 
					          roomIdOrAlias={roomIdOrAlias}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					      {settingsModalState.isOpen && (
 | 
				
			||||||
 | 
					        <SettingsModal
 | 
				
			||||||
 | 
					          client={client}
 | 
				
			||||||
 | 
					          roomId={groupCall.room.roomId}
 | 
				
			||||||
 | 
					          {...settingsModalProps}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      {inviteModalState.isOpen && (
 | 
				
			||||||
 | 
					        <InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,143 +0,0 @@
 | 
				
			||||||
/*
 | 
					 | 
				
			||||||
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 } from "react";
 | 
					 | 
				
			||||||
import { Item } from "@react-stately/collections";
 | 
					 | 
				
			||||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
					 | 
				
			||||||
import { OverlayTriggerState } from "@react-stately/overlays";
 | 
					 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Button } from "../button";
 | 
					 | 
				
			||||||
import { Menu } from "../Menu";
 | 
					 | 
				
			||||||
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
 | 
					 | 
				
			||||||
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
 | 
					 | 
				
			||||||
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
 | 
					 | 
				
			||||||
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
 | 
					 | 
				
			||||||
import { ReactComponent as FeedbackIcon } from "../icons/Feedback.svg";
 | 
					 | 
				
			||||||
import { useModalTriggerState } from "../Modal";
 | 
					 | 
				
			||||||
import { SettingsModal } from "../settings/SettingsModal";
 | 
					 | 
				
			||||||
import { InviteModal } from "./InviteModal";
 | 
					 | 
				
			||||||
import { TooltipTrigger } from "../Tooltip";
 | 
					 | 
				
			||||||
import { FeedbackModal } from "./FeedbackModal";
 | 
					 | 
				
			||||||
import { Config } from "../config/Config";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface Props {
 | 
					 | 
				
			||||||
  roomIdOrAlias: string;
 | 
					 | 
				
			||||||
  inCall: boolean;
 | 
					 | 
				
			||||||
  groupCall: GroupCall;
 | 
					 | 
				
			||||||
  showInvite: boolean;
 | 
					 | 
				
			||||||
  feedbackModalState: OverlayTriggerState;
 | 
					 | 
				
			||||||
  feedbackModalProps: {
 | 
					 | 
				
			||||||
    isOpen: boolean;
 | 
					 | 
				
			||||||
    onClose: () => void;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function OverflowMenu({
 | 
					 | 
				
			||||||
  roomIdOrAlias,
 | 
					 | 
				
			||||||
  inCall,
 | 
					 | 
				
			||||||
  groupCall,
 | 
					 | 
				
			||||||
  showInvite,
 | 
					 | 
				
			||||||
  feedbackModalState,
 | 
					 | 
				
			||||||
  feedbackModalProps,
 | 
					 | 
				
			||||||
}: Props) {
 | 
					 | 
				
			||||||
  const { t } = useTranslation();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const {
 | 
					 | 
				
			||||||
    modalState: inviteModalState,
 | 
					 | 
				
			||||||
    modalProps: inviteModalProps,
 | 
					 | 
				
			||||||
  }: {
 | 
					 | 
				
			||||||
    modalState: OverlayTriggerState;
 | 
					 | 
				
			||||||
    modalProps: {
 | 
					 | 
				
			||||||
      isOpen: boolean;
 | 
					 | 
				
			||||||
      onClose: () => void;
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  } = useModalTriggerState();
 | 
					 | 
				
			||||||
  const {
 | 
					 | 
				
			||||||
    modalState: settingsModalState,
 | 
					 | 
				
			||||||
    modalProps: settingsModalProps,
 | 
					 | 
				
			||||||
  }: {
 | 
					 | 
				
			||||||
    modalState: OverlayTriggerState;
 | 
					 | 
				
			||||||
    modalProps: {
 | 
					 | 
				
			||||||
      isOpen: boolean;
 | 
					 | 
				
			||||||
      onClose: () => void;
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  } = useModalTriggerState();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // TODO: On closing modal, focus should be restored to the trigger button
 | 
					 | 
				
			||||||
  // https://github.com/adobe/react-spectrum/issues/2444
 | 
					 | 
				
			||||||
  const onAction = useCallback(
 | 
					 | 
				
			||||||
    (key) => {
 | 
					 | 
				
			||||||
      switch (key) {
 | 
					 | 
				
			||||||
        case "invite":
 | 
					 | 
				
			||||||
          inviteModalState.open();
 | 
					 | 
				
			||||||
          break;
 | 
					 | 
				
			||||||
        case "settings":
 | 
					 | 
				
			||||||
          settingsModalState.open();
 | 
					 | 
				
			||||||
          break;
 | 
					 | 
				
			||||||
        case "feedback":
 | 
					 | 
				
			||||||
          feedbackModalState.open();
 | 
					 | 
				
			||||||
          break;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    [feedbackModalState, inviteModalState, settingsModalState]
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const tooltip = useCallback(() => t("More"), [t]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <>
 | 
					 | 
				
			||||||
      <PopoverMenuTrigger disableOnState>
 | 
					 | 
				
			||||||
        <TooltipTrigger tooltip={tooltip} placement="top">
 | 
					 | 
				
			||||||
          <Button variant="toolbar" data-testid="call_more">
 | 
					 | 
				
			||||||
            <OverflowIcon />
 | 
					 | 
				
			||||||
          </Button>
 | 
					 | 
				
			||||||
        </TooltipTrigger>
 | 
					 | 
				
			||||||
        {(props: JSX.IntrinsicAttributes) => (
 | 
					 | 
				
			||||||
          <Menu {...props} label={t("More menu")} onAction={onAction}>
 | 
					 | 
				
			||||||
            {showInvite && (
 | 
					 | 
				
			||||||
              <Item key="invite" textValue={t("Invite people")}>
 | 
					 | 
				
			||||||
                <AddUserIcon />
 | 
					 | 
				
			||||||
                <span data-testid="call_moreInvite">{t("Invite people")}</span>
 | 
					 | 
				
			||||||
              </Item>
 | 
					 | 
				
			||||||
            )}
 | 
					 | 
				
			||||||
            <Item key="settings" textValue={t("Settings")}>
 | 
					 | 
				
			||||||
              <SettingsIcon />
 | 
					 | 
				
			||||||
              <span>{t("Settings")}</span>
 | 
					 | 
				
			||||||
            </Item>
 | 
					 | 
				
			||||||
            {Config.get().rageshake?.submit_url && (
 | 
					 | 
				
			||||||
              <Item key="feedback" textValue={t("Submit feedback")}>
 | 
					 | 
				
			||||||
                <FeedbackIcon />
 | 
					 | 
				
			||||||
                <span>{t("Submit feedback")}</span>
 | 
					 | 
				
			||||||
              </Item>
 | 
					 | 
				
			||||||
            )}
 | 
					 | 
				
			||||||
          </Menu>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
      </PopoverMenuTrigger>
 | 
					 | 
				
			||||||
      {settingsModalState.isOpen && <SettingsModal {...settingsModalProps} />}
 | 
					 | 
				
			||||||
      {inviteModalState.isOpen && (
 | 
					 | 
				
			||||||
        <InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
      {feedbackModalState.isOpen && (
 | 
					 | 
				
			||||||
        <FeedbackModal
 | 
					 | 
				
			||||||
          {...feedbackModalProps}
 | 
					 | 
				
			||||||
          roomId={groupCall?.room.roomId}
 | 
					 | 
				
			||||||
          inCall={inCall}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -27,7 +27,7 @@ import { useTranslation } from "react-i18next";
 | 
				
			||||||
import { useDelayedState } from "../useDelayedState";
 | 
					import { useDelayedState } from "../useDelayedState";
 | 
				
			||||||
import { useModalTriggerState } from "../Modal";
 | 
					import { useModalTriggerState } from "../Modal";
 | 
				
			||||||
import { InviteModal } from "./InviteModal";
 | 
					import { InviteModal } from "./InviteModal";
 | 
				
			||||||
import { HangupButton, InviteButton } from "../button";
 | 
					import { HangupButton, InviteButton, SettingsButton } from "../button";
 | 
				
			||||||
import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
 | 
					import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
 | 
				
			||||||
import styles from "./PTTCallView.module.css";
 | 
					import styles from "./PTTCallView.module.css";
 | 
				
			||||||
import { Facepile } from "../Facepile";
 | 
					import { Facepile } from "../Facepile";
 | 
				
			||||||
| 
						 | 
					@ -41,10 +41,10 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
 | 
				
			||||||
import { usePTTSounds } from "../sound/usePttSounds";
 | 
					import { usePTTSounds } from "../sound/usePttSounds";
 | 
				
			||||||
import { PTTClips } from "../sound/PTTClips";
 | 
					import { PTTClips } from "../sound/PTTClips";
 | 
				
			||||||
import { GroupCallInspector } from "./GroupCallInspector";
 | 
					import { GroupCallInspector } from "./GroupCallInspector";
 | 
				
			||||||
import { OverflowMenu } from "./OverflowMenu";
 | 
					 | 
				
			||||||
import { Size } from "../Avatar";
 | 
					import { Size } from "../Avatar";
 | 
				
			||||||
import { ParticipantInfo } from "./useGroupCall";
 | 
					import { ParticipantInfo } from "./useGroupCall";
 | 
				
			||||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
 | 
					import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
 | 
				
			||||||
 | 
					import { SettingsModal } from "../settings/SettingsModal";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function getPromptText(
 | 
					function getPromptText(
 | 
				
			||||||
  networkWaiting: boolean,
 | 
					  networkWaiting: boolean,
 | 
				
			||||||
| 
						 | 
					@ -126,8 +126,9 @@ export const PTTCallView: React.FC<Props> = ({
 | 
				
			||||||
  const { t } = useTranslation();
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
  const { modalState: inviteModalState, modalProps: inviteModalProps } =
 | 
					  const { modalState: inviteModalState, modalProps: inviteModalProps } =
 | 
				
			||||||
    useModalTriggerState();
 | 
					    useModalTriggerState();
 | 
				
			||||||
  const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
 | 
					  const { modalState: settingsModalState, modalProps: settingsModalProps } =
 | 
				
			||||||
    useModalTriggerState();
 | 
					    useModalTriggerState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
 | 
					  const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
 | 
				
			||||||
  const facepileSize = bounds.width < 800 ? Size.SM : Size.MD;
 | 
					  const facepileSize = bounds.width < 800 ? Size.SM : Size.MD;
 | 
				
			||||||
  const showControls = bounds.height > 500;
 | 
					  const showControls = bounds.height > 500;
 | 
				
			||||||
| 
						 | 
					@ -232,14 +233,7 @@ export const PTTCallView: React.FC<Props> = ({
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div className={styles.footer}>
 | 
					          <div className={styles.footer}>
 | 
				
			||||||
            <OverflowMenu
 | 
					            <SettingsButton onPress={() => settingsModalState.open()} />
 | 
				
			||||||
              inCall
 | 
					 | 
				
			||||||
              roomIdOrAlias={roomIdOrAlias}
 | 
					 | 
				
			||||||
              groupCall={groupCall}
 | 
					 | 
				
			||||||
              showInvite={false}
 | 
					 | 
				
			||||||
              feedbackModalState={feedbackModalState}
 | 
					 | 
				
			||||||
              feedbackModalProps={feedbackModalProps}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            {!isEmbedded && <HangupButton onPress={onLeave} />}
 | 
					            {!isEmbedded && <HangupButton onPress={onLeave} />}
 | 
				
			||||||
            <InviteButton onPress={() => inviteModalState.open()} />
 | 
					            <InviteButton onPress={() => inviteModalState.open()} />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
| 
						 | 
					@ -265,7 +259,7 @@ export const PTTCallView: React.FC<Props> = ({
 | 
				
			||||||
              <div className={styles.talkingInfo} />
 | 
					              <div className={styles.talkingInfo} />
 | 
				
			||||||
            ))}
 | 
					            ))}
 | 
				
			||||||
          <PTTButton
 | 
					          <PTTButton
 | 
				
			||||||
            enabled={!feedbackModalState.isOpen}
 | 
					            enabled={!inviteModalState.isOpen && !settingsModalState.isOpen}
 | 
				
			||||||
            showTalkOverError={showTalkOverError}
 | 
					            showTalkOverError={showTalkOverError}
 | 
				
			||||||
            activeSpeakerUserId={activeSpeakerUserId}
 | 
					            activeSpeakerUserId={activeSpeakerUserId}
 | 
				
			||||||
            activeSpeakerDisplayName={activeSpeakerDisplayName}
 | 
					            activeSpeakerDisplayName={activeSpeakerDisplayName}
 | 
				
			||||||
| 
						 | 
					@ -312,6 +306,13 @@ export const PTTCallView: React.FC<Props> = ({
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {settingsModalState.isOpen && (
 | 
				
			||||||
 | 
					        <SettingsModal
 | 
				
			||||||
 | 
					          client={client}
 | 
				
			||||||
 | 
					          roomId={groupCall.room.roomId}
 | 
				
			||||||
 | 
					          {...settingsModalProps}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
      {inviteModalState.isOpen && showControls && (
 | 
					      {inviteModalState.isOpen && showControls && (
 | 
				
			||||||
        <InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
 | 
					        <InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2021-2022 New Vector Ltd
 | 
					Copyright 2021-2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -24,7 +24,6 @@ import { RoomAuthView } from "./RoomAuthView";
 | 
				
			||||||
import { GroupCallLoader } from "./GroupCallLoader";
 | 
					import { GroupCallLoader } from "./GroupCallLoader";
 | 
				
			||||||
import { GroupCallView } from "./GroupCallView";
 | 
					import { GroupCallView } from "./GroupCallView";
 | 
				
			||||||
import { useUrlParams } from "../UrlParams";
 | 
					import { useUrlParams } from "../UrlParams";
 | 
				
			||||||
import { MediaHandlerProvider } from "../settings/useMediaHandler";
 | 
					 | 
				
			||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
 | 
					import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
 | 
				
			||||||
import { translatedError } from "../TranslatedError";
 | 
					import { translatedError } from "../TranslatedError";
 | 
				
			||||||
import { useOptInAnalytics } from "../settings/useSetting";
 | 
					import { useOptInAnalytics } from "../settings/useSetting";
 | 
				
			||||||
| 
						 | 
					@ -101,7 +100,6 @@ export const RoomPage: FC = () => {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <MediaHandlerProvider client={client}>
 | 
					 | 
				
			||||||
    <GroupCallLoader
 | 
					    <GroupCallLoader
 | 
				
			||||||
      client={client}
 | 
					      client={client}
 | 
				
			||||||
      roomIdOrAlias={roomIdOrAlias}
 | 
					      roomIdOrAlias={roomIdOrAlias}
 | 
				
			||||||
| 
						 | 
					@ -110,6 +108,5 @@ export const RoomPage: FC = () => {
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      {groupCallView}
 | 
					      {groupCallView}
 | 
				
			||||||
    </GroupCallLoader>
 | 
					    </GroupCallLoader>
 | 
				
			||||||
    </MediaHandlerProvider>
 | 
					 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -14,21 +14,22 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React from "react";
 | 
					import React, { useCallback } from "react";
 | 
				
			||||||
import useMeasure from "react-use-measure";
 | 
					import useMeasure from "react-use-measure";
 | 
				
			||||||
import { ResizeObserver } from "@juggle/resize-observer";
 | 
					import { ResizeObserver } from "@juggle/resize-observer";
 | 
				
			||||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
					import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
				
			||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
					import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import { OverlayTriggerState } from "@react-stately/overlays";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { MicButton, VideoButton } from "../button";
 | 
					import { MicButton, SettingsButton, VideoButton } from "../button";
 | 
				
			||||||
import { useMediaStream } from "../video-grid/useMediaStream";
 | 
					import { useMediaStream } from "../video-grid/useMediaStream";
 | 
				
			||||||
import { OverflowMenu } from "./OverflowMenu";
 | 
					 | 
				
			||||||
import { Avatar } from "../Avatar";
 | 
					import { Avatar } from "../Avatar";
 | 
				
			||||||
import { useProfile } from "../profile/useProfile";
 | 
					import { useProfile } from "../profile/useProfile";
 | 
				
			||||||
import styles from "./VideoPreview.module.css";
 | 
					import styles from "./VideoPreview.module.css";
 | 
				
			||||||
import { Body } from "../typography/Typography";
 | 
					import { Body } from "../typography/Typography";
 | 
				
			||||||
import { useModalTriggerState } from "../Modal";
 | 
					import { useModalTriggerState } from "../Modal";
 | 
				
			||||||
 | 
					import { SettingsModal } from "../settings/SettingsModal";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
  client: MatrixClient;
 | 
					  client: MatrixClient;
 | 
				
			||||||
| 
						 | 
					@ -59,8 +60,20 @@ export function VideoPreview({
 | 
				
			||||||
  const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
 | 
					  const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
 | 
				
			||||||
  const avatarSize = (previewBounds.height - 66) / 2;
 | 
					  const avatarSize = (previewBounds.height - 66) / 2;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
 | 
					  const {
 | 
				
			||||||
    useModalTriggerState();
 | 
					    modalState: settingsModalState,
 | 
				
			||||||
 | 
					    modalProps: settingsModalProps,
 | 
				
			||||||
 | 
					  }: {
 | 
				
			||||||
 | 
					    modalState: OverlayTriggerState;
 | 
				
			||||||
 | 
					    modalProps: {
 | 
				
			||||||
 | 
					      isOpen: boolean;
 | 
				
			||||||
 | 
					      onClose: () => void;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  } = useModalTriggerState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const openSettings = useCallback(() => {
 | 
				
			||||||
 | 
					    settingsModalState.open();
 | 
				
			||||||
 | 
					  }, [settingsModalState]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className={styles.preview} ref={previewRef}>
 | 
					    <div className={styles.preview} ref={previewRef}>
 | 
				
			||||||
| 
						 | 
					@ -101,17 +114,13 @@ export function VideoPreview({
 | 
				
			||||||
              muted={localVideoMuted}
 | 
					              muted={localVideoMuted}
 | 
				
			||||||
              onPress={toggleLocalVideoMuted}
 | 
					              onPress={toggleLocalVideoMuted}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
            <OverflowMenu
 | 
					            <SettingsButton onPress={openSettings} />
 | 
				
			||||||
              roomIdOrAlias={roomIdOrAlias}
 | 
					 | 
				
			||||||
              feedbackModalState={feedbackModalState}
 | 
					 | 
				
			||||||
              feedbackModalProps={feedbackModalProps}
 | 
					 | 
				
			||||||
              inCall={false}
 | 
					 | 
				
			||||||
              groupCall={undefined}
 | 
					 | 
				
			||||||
              showInvite={false}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </>
 | 
					        </>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					      {settingsModalState.isOpen && (
 | 
				
			||||||
 | 
					        <SettingsModal client={client} {...settingsModalProps} />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -14,28 +14,21 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React, { useCallback, useEffect } from "react";
 | 
					import React, { useCallback } from "react";
 | 
				
			||||||
import { randomString } from "matrix-js-sdk/src/randomstring";
 | 
					import { randomString } from "matrix-js-sdk/src/randomstring";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Modal, ModalContent } from "../Modal";
 | 
					 | 
				
			||||||
import { Button } from "../button";
 | 
					import { Button } from "../button";
 | 
				
			||||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
 | 
					import { FieldRow, InputField, ErrorMessage } from "../input/Input";
 | 
				
			||||||
import {
 | 
					import { useSubmitRageshake, useRageshakeRequest } from "./submit-rageshake";
 | 
				
			||||||
  useSubmitRageshake,
 | 
					 | 
				
			||||||
  useRageshakeRequest,
 | 
					 | 
				
			||||||
} from "../settings/submit-rageshake";
 | 
					 | 
				
			||||||
import { Body } from "../typography/Typography";
 | 
					import { Body } from "../typography/Typography";
 | 
				
			||||||
 | 
					import styles from "../input/SelectInput.module.css";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
  inCall: boolean;
 | 
					  roomId?: string;
 | 
				
			||||||
  roomId: string;
 | 
					 | 
				
			||||||
  onClose?: () => void;
 | 
					 | 
				
			||||||
  // TODO: add all props for for <Modal>
 | 
					 | 
				
			||||||
  [index: string]: unknown;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
 | 
					export function FeedbackSettingsTab({ roomId }: Props) {
 | 
				
			||||||
  const { t } = useTranslation();
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
  const { submitRageshake, sending, sent, error } = useSubmitRageshake();
 | 
					  const { submitRageshake, sending, sent, error } = useSubmitRageshake();
 | 
				
			||||||
  const sendRageshakeRequest = useRageshakeRequest();
 | 
					  const sendRageshakeRequest = useRageshakeRequest();
 | 
				
			||||||
| 
						 | 
					@ -57,37 +50,34 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
 | 
				
			||||||
        roomId,
 | 
					        roomId,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (inCall && sendLogs) {
 | 
					      if (roomId && sendLogs) {
 | 
				
			||||||
        sendRageshakeRequest(roomId, rageshakeRequestId);
 | 
					        sendRageshakeRequest(roomId, rageshakeRequestId);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [inCall, submitRageshake, roomId, sendRageshakeRequest]
 | 
					    [submitRageshake, roomId, sendRageshakeRequest]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    if (sent) {
 | 
					 | 
				
			||||||
      onClose();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }, [sent, onClose]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Modal
 | 
					    <div>
 | 
				
			||||||
      title={t("Submit feedback")}
 | 
					      <h4 className={styles.label}>{t("Submit feedback")}</h4>
 | 
				
			||||||
      isDismissable
 | 
					      <Body>
 | 
				
			||||||
      onClose={onClose}
 | 
					        {t(
 | 
				
			||||||
      {...rest}
 | 
					          "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below."
 | 
				
			||||||
    >
 | 
					        )}
 | 
				
			||||||
      <ModalContent>
 | 
					      </Body>
 | 
				
			||||||
        <Body>{t("Having trouble? Help us fix it.")}</Body>
 | 
					 | 
				
			||||||
      <form onSubmit={onSubmitFeedback}>
 | 
					      <form onSubmit={onSubmitFeedback}>
 | 
				
			||||||
        <FieldRow>
 | 
					        <FieldRow>
 | 
				
			||||||
          <InputField
 | 
					          <InputField
 | 
				
			||||||
            id="description"
 | 
					            id="description"
 | 
				
			||||||
            name="description"
 | 
					            name="description"
 | 
				
			||||||
              label={t("Description (optional)")}
 | 
					            label={t("Your feedback")}
 | 
				
			||||||
            type="textarea"
 | 
					            type="textarea"
 | 
				
			||||||
 | 
					            disabled={sending || sent}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </FieldRow>
 | 
					        </FieldRow>
 | 
				
			||||||
 | 
					        {sent ? (
 | 
				
			||||||
 | 
					          <Body> {t("Thanks, we received your feedback!")}</Body>
 | 
				
			||||||
 | 
					        ) : (
 | 
				
			||||||
          <FieldRow>
 | 
					          <FieldRow>
 | 
				
			||||||
            <InputField
 | 
					            <InputField
 | 
				
			||||||
              id="sendLogs"
 | 
					              id="sendLogs"
 | 
				
			||||||
| 
						 | 
					@ -96,19 +86,17 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
 | 
				
			||||||
              type="checkbox"
 | 
					              type="checkbox"
 | 
				
			||||||
              defaultChecked
 | 
					              defaultChecked
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </FieldRow>
 | 
					 | 
				
			||||||
            {error && (
 | 
					            {error && (
 | 
				
			||||||
              <FieldRow>
 | 
					              <FieldRow>
 | 
				
			||||||
                <ErrorMessage error={error} />
 | 
					                <ErrorMessage error={error} />
 | 
				
			||||||
              </FieldRow>
 | 
					              </FieldRow>
 | 
				
			||||||
            )}
 | 
					            )}
 | 
				
			||||||
          <FieldRow>
 | 
					 | 
				
			||||||
            <Button type="submit" disabled={sending}>
 | 
					            <Button type="submit" disabled={sending}>
 | 
				
			||||||
              {sending ? t("Submitting feedback…") : t("Submit feedback")}
 | 
					              {sending ? t("Submitting…") : t("Submit")}
 | 
				
			||||||
            </Button>
 | 
					            </Button>
 | 
				
			||||||
          </FieldRow>
 | 
					          </FieldRow>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
      </form>
 | 
					      </form>
 | 
				
			||||||
      </ModalContent>
 | 
					    </div>
 | 
				
			||||||
    </Modal>
 | 
					 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.content {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  max-width: 350px;
 | 
				
			||||||
 | 
					  align-self: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.avatarFieldRow {
 | 
					.avatarFieldRow {
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										113
									
								
								src/settings/ProfileSettingsTab.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/settings/ProfileSettingsTab.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,113 @@
 | 
				
			||||||
 | 
					/*
 | 
				
			||||||
 | 
					Copyright 2022 - 2023 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, useEffect, useRef } from "react";
 | 
				
			||||||
 | 
					import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { useProfile } from "../profile/useProfile";
 | 
				
			||||||
 | 
					import { FieldRow, InputField, ErrorMessage } from "../input/Input";
 | 
				
			||||||
 | 
					import { AvatarInputField } from "../input/AvatarInputField";
 | 
				
			||||||
 | 
					import styles from "./ProfileSettingsTab.module.css";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  client: MatrixClient;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					export function ProfileSettingsTab({ client }: Props) {
 | 
				
			||||||
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
 | 
					  const { error, displayName, avatarUrl, saveProfile } = useProfile(client);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const formRef = useRef<HTMLFormElement | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const formChanged = useRef(false);
 | 
				
			||||||
 | 
					  const onFormChange = useCallback(() => {
 | 
				
			||||||
 | 
					    formChanged.current = true;
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const removeAvatar = useRef(false);
 | 
				
			||||||
 | 
					  const onRemoveAvatar = useCallback(() => {
 | 
				
			||||||
 | 
					    removeAvatar.current = true;
 | 
				
			||||||
 | 
					    formChanged.current = true;
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const form = formRef.current!;
 | 
				
			||||||
 | 
					    // Auto-save when the user dismisses this component
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      if (formChanged.current) {
 | 
				
			||||||
 | 
					        const data = new FormData(form);
 | 
				
			||||||
 | 
					        const displayNameDataEntry = data.get("displayName");
 | 
				
			||||||
 | 
					        const avatar = data.get("avatar");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const avatarSize =
 | 
				
			||||||
 | 
					          typeof avatar == "string" ? avatar.length : avatar?.size ?? 0;
 | 
				
			||||||
 | 
					        const displayName =
 | 
				
			||||||
 | 
					          typeof displayNameDataEntry == "string"
 | 
				
			||||||
 | 
					            ? displayNameDataEntry
 | 
				
			||||||
 | 
					            : displayNameDataEntry?.name ?? null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        saveProfile({
 | 
				
			||||||
 | 
					          displayName,
 | 
				
			||||||
 | 
					          avatar: avatar && avatarSize > 0 ? avatar : undefined,
 | 
				
			||||||
 | 
					          removeAvatar: removeAvatar.current && (!avatar || avatarSize === 0),
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [saveProfile]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <form onChange={onFormChange} ref={formRef} className={styles.content}>
 | 
				
			||||||
 | 
					      <FieldRow className={styles.avatarFieldRow}>
 | 
				
			||||||
 | 
					        <AvatarInputField
 | 
				
			||||||
 | 
					          id="avatar"
 | 
				
			||||||
 | 
					          name="avatar"
 | 
				
			||||||
 | 
					          label={t("Avatar")}
 | 
				
			||||||
 | 
					          avatarUrl={avatarUrl}
 | 
				
			||||||
 | 
					          displayName={displayName}
 | 
				
			||||||
 | 
					          onRemoveAvatar={onRemoveAvatar}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </FieldRow>
 | 
				
			||||||
 | 
					      <FieldRow>
 | 
				
			||||||
 | 
					        <InputField
 | 
				
			||||||
 | 
					          id="userId"
 | 
				
			||||||
 | 
					          name="userId"
 | 
				
			||||||
 | 
					          label={t("Username")}
 | 
				
			||||||
 | 
					          type="text"
 | 
				
			||||||
 | 
					          disabled
 | 
				
			||||||
 | 
					          value={client.getUserId()!}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </FieldRow>
 | 
				
			||||||
 | 
					      <FieldRow>
 | 
				
			||||||
 | 
					        <InputField
 | 
				
			||||||
 | 
					          id="displayName"
 | 
				
			||||||
 | 
					          name="displayName"
 | 
				
			||||||
 | 
					          label={t("Display name")}
 | 
				
			||||||
 | 
					          type="text"
 | 
				
			||||||
 | 
					          required
 | 
				
			||||||
 | 
					          autoComplete="off"
 | 
				
			||||||
 | 
					          placeholder={t("Display name")}
 | 
				
			||||||
 | 
					          defaultValue={displayName}
 | 
				
			||||||
 | 
					          data-testid="profile_displayname"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </FieldRow>
 | 
				
			||||||
 | 
					      {error && (
 | 
				
			||||||
 | 
					        <FieldRow>
 | 
				
			||||||
 | 
					          <ErrorMessage error={error} />
 | 
				
			||||||
 | 
					        </FieldRow>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -19,6 +19,10 @@ limitations under the License.
 | 
				
			||||||
  height: 480px;
 | 
					  height: 480px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.settingsModal p {
 | 
				
			||||||
 | 
					  color: var(--secondary-content);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tabContainer {
 | 
					.tabContainer {
 | 
				
			||||||
  padding: 27px 20px;
 | 
					  padding: 27px 20px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -26,12 +30,3 @@ limitations under the License.
 | 
				
			||||||
.fieldRowText {
 | 
					.fieldRowText {
 | 
				
			||||||
  margin-bottom: 0;
 | 
					  margin-bottom: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
/*
 | 
					 | 
				
			||||||
This style guarantees a fixed width of the tab bar in the settings window.
 | 
					 | 
				
			||||||
The "Developer" item in the tab bar can be toggled.
 | 
					 | 
				
			||||||
Without a defined width activating the developer tab makes the tab container jump to the right.
 | 
					 | 
				
			||||||
*/
 | 
					 | 
				
			||||||
.tabLabel {
 | 
					 | 
				
			||||||
  min-width: 80px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React from "react";
 | 
					import React, { useCallback, useState } from "react";
 | 
				
			||||||
import { Item } from "@react-stately/collections";
 | 
					import { Item } from "@react-stately/collections";
 | 
				
			||||||
import { Trans, useTranslation } from "react-i18next";
 | 
					import { Trans, useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import { MatrixClient } from "matrix-js-sdk";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Modal } from "../Modal";
 | 
					import { Modal } from "../Modal";
 | 
				
			||||||
import styles from "./SettingsModal.module.css";
 | 
					import styles from "./SettingsModal.module.css";
 | 
				
			||||||
| 
						 | 
					@ -25,6 +26,8 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
 | 
				
			||||||
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
 | 
					import { ReactComponent as VideoIcon } from "../icons/Video.svg";
 | 
				
			||||||
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
 | 
					import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
 | 
				
			||||||
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
 | 
					import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
 | 
				
			||||||
 | 
					import { ReactComponent as UserIcon } from "../icons/User.svg";
 | 
				
			||||||
 | 
					import { ReactComponent as FeedbackIcon } from "../icons/Feedback.svg";
 | 
				
			||||||
import { SelectInput } from "../input/SelectInput";
 | 
					import { SelectInput } from "../input/SelectInput";
 | 
				
			||||||
import { useMediaHandler } from "./useMediaHandler";
 | 
					import { useMediaHandler } from "./useMediaHandler";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
| 
						 | 
					@ -39,9 +42,14 @@ import { Button } from "../button";
 | 
				
			||||||
import { useDownloadDebugLog } from "./submit-rageshake";
 | 
					import { useDownloadDebugLog } from "./submit-rageshake";
 | 
				
			||||||
import { Body, Caption } from "../typography/Typography";
 | 
					import { Body, Caption } from "../typography/Typography";
 | 
				
			||||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
 | 
					import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
 | 
				
			||||||
 | 
					import { ProfileSettingsTab } from "./ProfileSettingsTab";
 | 
				
			||||||
 | 
					import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
  isOpen: boolean;
 | 
					  isOpen: boolean;
 | 
				
			||||||
 | 
					  client: MatrixClient;
 | 
				
			||||||
 | 
					  roomId?: string;
 | 
				
			||||||
 | 
					  defaultTab?: string;
 | 
				
			||||||
  onClose: () => void;
 | 
					  onClose: () => void;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -70,6 +78,15 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const downloadDebugLog = useDownloadDebugLog();
 | 
					  const downloadDebugLog = useDownloadDebugLog();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [selectedTab, setSelectedTab] = useState<string | undefined>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onSelectedTabChanged = useCallback(
 | 
				
			||||||
 | 
					    (tab) => {
 | 
				
			||||||
 | 
					      setSelectedTab(tab);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [setSelectedTab]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const optInDescription = (
 | 
					  const optInDescription = (
 | 
				
			||||||
    <Caption>
 | 
					    <Caption>
 | 
				
			||||||
      <Trans>
 | 
					      <Trans>
 | 
				
			||||||
| 
						 | 
					@ -89,8 +106,13 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
      className={styles.settingsModal}
 | 
					      className={styles.settingsModal}
 | 
				
			||||||
      {...props}
 | 
					      {...props}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <TabContainer className={styles.tabContainer}>
 | 
					      <TabContainer
 | 
				
			||||||
 | 
					        onSelectionChange={onSelectedTabChanged}
 | 
				
			||||||
 | 
					        selectedKey={selectedTab ?? props.defaultTab ?? "audio"}
 | 
				
			||||||
 | 
					        className={styles.tabContainer}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <TabItem
 | 
					        <TabItem
 | 
				
			||||||
 | 
					          key="audio"
 | 
				
			||||||
          title={
 | 
					          title={
 | 
				
			||||||
            <>
 | 
					            <>
 | 
				
			||||||
              <AudioIcon width={16} height={16} />
 | 
					              <AudioIcon width={16} height={16} />
 | 
				
			||||||
| 
						 | 
					@ -147,6 +169,7 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
          </FieldRow>
 | 
					          </FieldRow>
 | 
				
			||||||
        </TabItem>
 | 
					        </TabItem>
 | 
				
			||||||
        <TabItem
 | 
					        <TabItem
 | 
				
			||||||
 | 
					          key="video"
 | 
				
			||||||
          title={
 | 
					          title={
 | 
				
			||||||
            <>
 | 
					            <>
 | 
				
			||||||
              <VideoIcon width={16} height={16} />
 | 
					              <VideoIcon width={16} height={16} />
 | 
				
			||||||
| 
						 | 
					@ -169,6 +192,29 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
          </SelectInput>
 | 
					          </SelectInput>
 | 
				
			||||||
        </TabItem>
 | 
					        </TabItem>
 | 
				
			||||||
        <TabItem
 | 
					        <TabItem
 | 
				
			||||||
 | 
					          key="profile"
 | 
				
			||||||
 | 
					          title={
 | 
				
			||||||
 | 
					            <>
 | 
				
			||||||
 | 
					              <UserIcon width={15} height={15} />
 | 
				
			||||||
 | 
					              <span>{t("Profile")}</span>
 | 
				
			||||||
 | 
					            </>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <ProfileSettingsTab client={props.client} />
 | 
				
			||||||
 | 
					        </TabItem>
 | 
				
			||||||
 | 
					        <TabItem
 | 
				
			||||||
 | 
					          key="feedback"
 | 
				
			||||||
 | 
					          title={
 | 
				
			||||||
 | 
					            <>
 | 
				
			||||||
 | 
					              <FeedbackIcon width={16} height={16} />
 | 
				
			||||||
 | 
					              <span>{t("Feedback")}</span>
 | 
				
			||||||
 | 
					            </>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <FeedbackSettingsTab roomId={props.roomId} />
 | 
				
			||||||
 | 
					        </TabItem>
 | 
				
			||||||
 | 
					        <TabItem
 | 
				
			||||||
 | 
					          key="more"
 | 
				
			||||||
          title={
 | 
					          title={
 | 
				
			||||||
            <>
 | 
					            <>
 | 
				
			||||||
              <OverflowIcon width={16} height={16} />
 | 
					              <OverflowIcon width={16} height={16} />
 | 
				
			||||||
| 
						 | 
					@ -176,18 +222,10 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
            </>
 | 
					            </>
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <h4>Analytics</h4>
 | 
					          <h4>Developer</h4>
 | 
				
			||||||
          <FieldRow>
 | 
					          <p>
 | 
				
			||||||
            <InputField
 | 
					            Version: {(import.meta.env.VITE_APP_VERSION as string) || "dev"}
 | 
				
			||||||
              id="optInAnalytics"
 | 
					          </p>
 | 
				
			||||||
              type="checkbox"
 | 
					 | 
				
			||||||
              checked={optInAnalytics}
 | 
					 | 
				
			||||||
              description={optInDescription}
 | 
					 | 
				
			||||||
              onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
 | 
					 | 
				
			||||||
                setOptInAnalytics(event.target.checked)
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          </FieldRow>
 | 
					 | 
				
			||||||
          <FieldRow>
 | 
					          <FieldRow>
 | 
				
			||||||
            <InputField
 | 
					            <InputField
 | 
				
			||||||
              id="developerSettingsTab"
 | 
					              id="developerSettingsTab"
 | 
				
			||||||
| 
						 | 
					@ -202,9 +240,22 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </FieldRow>
 | 
					          </FieldRow>
 | 
				
			||||||
 | 
					          <h4>Analytics</h4>
 | 
				
			||||||
 | 
					          <FieldRow>
 | 
				
			||||||
 | 
					            <InputField
 | 
				
			||||||
 | 
					              id="optInAnalytics"
 | 
				
			||||||
 | 
					              type="checkbox"
 | 
				
			||||||
 | 
					              checked={optInAnalytics}
 | 
				
			||||||
 | 
					              description={optInDescription}
 | 
				
			||||||
 | 
					              onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
 | 
				
			||||||
 | 
					                setOptInAnalytics(event.target.checked)
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          </FieldRow>
 | 
				
			||||||
        </TabItem>
 | 
					        </TabItem>
 | 
				
			||||||
        {developerSettingsTab && (
 | 
					        {developerSettingsTab && (
 | 
				
			||||||
          <TabItem
 | 
					          <TabItem
 | 
				
			||||||
 | 
					            key="developer"
 | 
				
			||||||
            title={
 | 
					            title={
 | 
				
			||||||
              <>
 | 
					              <>
 | 
				
			||||||
                <DeveloperIcon width={16} height={16} />
 | 
					                <DeveloperIcon width={16} height={16} />
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
Copyright 2022 New Vector Ltd
 | 
					Copyright 2022 - 2023 New Vector Ltd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
					Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
you may not use this file except in compliance with the License.
 | 
					you may not use this file except in compliance with the License.
 | 
				
			||||||
| 
						 | 
					@ -14,23 +14,6 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
 | 
					 | 
				
			||||||
/*
 | 
					 | 
				
			||||||
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 { MatrixClient } from "matrix-js-sdk/src/client";
 | 
					import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
				
			||||||
import React, {
 | 
					import React, {
 | 
				
			||||||
  useState,
 | 
					  useState,
 | 
				
			||||||
| 
						 | 
					@ -43,6 +26,7 @@ import React, {
 | 
				
			||||||
  useRef,
 | 
					  useRef,
 | 
				
			||||||
} from "react";
 | 
					} from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { useClient } from "../ClientContext";
 | 
				
			||||||
import { getNamedDevices } from "../media-utils";
 | 
					import { getNamedDevices } from "../media-utils";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface MediaHandlerContextInterface {
 | 
					export interface MediaHandlerContextInterface {
 | 
				
			||||||
| 
						 | 
					@ -70,6 +54,7 @@ interface MediaPreferences {
 | 
				
			||||||
  videoInput?: string;
 | 
					  videoInput?: string;
 | 
				
			||||||
  audioOutput?: string;
 | 
					  audioOutput?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function getMediaPreferences(): MediaPreferences {
 | 
					function getMediaPreferences(): MediaPreferences {
 | 
				
			||||||
  const mediaPreferences = localStorage.getItem("matrix-media-preferences");
 | 
					  const mediaPreferences = localStorage.getItem("matrix-media-preferences");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -95,11 +80,13 @@ function updateMediaPreferences(newPreferences: MediaPreferences): void {
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
  client: MatrixClient;
 | 
					 | 
				
			||||||
  children: ReactNode;
 | 
					  children: ReactNode;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
 | 
					
 | 
				
			||||||
 | 
					export function MediaHandlerProvider({ children }: Props): JSX.Element {
 | 
				
			||||||
 | 
					  const { client } = useClient();
 | 
				
			||||||
  const [
 | 
					  const [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      audioInput,
 | 
					      audioInput,
 | 
				
			||||||
| 
						 | 
					@ -124,7 +111,7 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
 | 
				
			||||||
  const numComponentsWantingNames = useRef(0);
 | 
					  const numComponentsWantingNames = useRef(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const updateDevices = useCallback(
 | 
					  const updateDevices = useCallback(
 | 
				
			||||||
    async (initial: boolean) => {
 | 
					    async (client: MatrixClient, initial: boolean) => {
 | 
				
			||||||
      // Only request device names if components actually want them, because it
 | 
					      // Only request device names if components actually want them, because it
 | 
				
			||||||
      // could trigger an extra permission pop-up
 | 
					      // could trigger an extra permission pop-up
 | 
				
			||||||
      const devices = await (numComponentsWantingNames.current > 0
 | 
					      const devices = await (numComponentsWantingNames.current > 0
 | 
				
			||||||
| 
						 | 
					@ -175,7 +162,7 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
 | 
				
			||||||
        client.getMediaHandler().setMediaInputs(audioInput, videoInput);
 | 
					        client.getMediaHandler().setMediaInputs(audioInput, videoInput);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [client, setState]
 | 
					    [setState]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const useDeviceNames = useCallback(() => {
 | 
					  const useDeviceNames = useCallback(() => {
 | 
				
			||||||
| 
						 | 
					@ -183,15 +170,19 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
 | 
				
			||||||
    // dynamic hook, but it works
 | 
					    // dynamic hook, but it works
 | 
				
			||||||
    // eslint-disable-next-line react-hooks/rules-of-hooks
 | 
					    // eslint-disable-next-line react-hooks/rules-of-hooks
 | 
				
			||||||
    useEffect(() => {
 | 
					    useEffect(() => {
 | 
				
			||||||
 | 
					      if (client) {
 | 
				
			||||||
        numComponentsWantingNames.current++;
 | 
					        numComponentsWantingNames.current++;
 | 
				
			||||||
      if (numComponentsWantingNames.current === 1) updateDevices(false);
 | 
					        if (numComponentsWantingNames.current === 1)
 | 
				
			||||||
 | 
					          updateDevices(client, false);
 | 
				
			||||||
        return () => void numComponentsWantingNames.current--;
 | 
					        return () => void numComponentsWantingNames.current--;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }, []);
 | 
					    }, []);
 | 
				
			||||||
  }, [updateDevices]);
 | 
					  }, [client, updateDevices]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    updateDevices(true);
 | 
					    if (client) {
 | 
				
			||||||
    const onDeviceChange = () => updateDevices(false);
 | 
					      updateDevices(client, true);
 | 
				
			||||||
 | 
					      const onDeviceChange = () => updateDevices(client, false);
 | 
				
			||||||
      navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
 | 
					      navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return () => {
 | 
					      return () => {
 | 
				
			||||||
| 
						 | 
					@ -201,13 +192,14 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        client.getMediaHandler().stopAllStreams();
 | 
					        client.getMediaHandler().stopAllStreams();
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }, [client, updateDevices]);
 | 
					  }, [client, updateDevices]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const setAudioInput: (deviceId: string) => void = useCallback(
 | 
					  const setAudioInput: (deviceId: string) => void = useCallback(
 | 
				
			||||||
    (deviceId: string) => {
 | 
					    (deviceId: string) => {
 | 
				
			||||||
      updateMediaPreferences({ audioInput: deviceId });
 | 
					      updateMediaPreferences({ audioInput: deviceId });
 | 
				
			||||||
      setState((prevState) => ({ ...prevState, audioInput: deviceId }));
 | 
					      setState((prevState) => ({ ...prevState, audioInput: deviceId }));
 | 
				
			||||||
      client.getMediaHandler().setAudioInput(deviceId);
 | 
					      client?.getMediaHandler().setAudioInput(deviceId);
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [client]
 | 
					    [client]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -25,12 +25,14 @@ limitations under the License.
 | 
				
			||||||
  list-style: none;
 | 
					  list-style: none;
 | 
				
			||||||
  padding: 0;
 | 
					  padding: 0;
 | 
				
			||||||
  margin: 0 auto 24px auto;
 | 
					  margin: 0 auto 24px auto;
 | 
				
			||||||
 | 
					  gap: 16px;
 | 
				
			||||||
 | 
					  overflow: scroll;
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tab {
 | 
					.tab {
 | 
				
			||||||
  max-width: 190px;
 | 
					 | 
				
			||||||
  min-width: fit-content;
 | 
					 | 
				
			||||||
  height: 32px;
 | 
					  height: 32px;
 | 
				
			||||||
 | 
					  box-sizing: border-box;
 | 
				
			||||||
  border-radius: 8px;
 | 
					  border-radius: 8px;
 | 
				
			||||||
  background-color: transparent;
 | 
					  background-color: transparent;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
| 
						 | 
					@ -38,6 +40,7 @@ limitations under the License.
 | 
				
			||||||
  padding: 0 8px;
 | 
					  padding: 0 8px;
 | 
				
			||||||
  border: none;
 | 
					  border: none;
 | 
				
			||||||
  cursor: pointer;
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  font-size: var(--font-size-body);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tab > * {
 | 
					.tab > * {
 | 
				
			||||||
| 
						 | 
					@ -78,17 +81,18 @@ limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@media (min-width: 800px) {
 | 
					@media (min-width: 800px) {
 | 
				
			||||||
  .tab {
 | 
					  .tab {
 | 
				
			||||||
 | 
					    width: 200px;
 | 
				
			||||||
    padding: 0 16px;
 | 
					    padding: 0 16px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .tab > * {
 | 
					  .tab > * {
 | 
				
			||||||
    margin: 0 16px 0 0;
 | 
					    margin: 0 12px 0 0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .tabContainer {
 | 
					  .tabContainer {
 | 
				
			||||||
    width: 100%;
 | 
					    width: 100%;
 | 
				
			||||||
    flex-direction: row;
 | 
					    flex-direction: row;
 | 
				
			||||||
    padding: 27px 20px;
 | 
					    padding: 20px 18px;
 | 
				
			||||||
    box-sizing: border-box;
 | 
					    box-sizing: border-box;
 | 
				
			||||||
    overflow: hidden;
 | 
					    overflow: hidden;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -96,6 +100,7 @@ limitations under the License.
 | 
				
			||||||
  .tabList {
 | 
					  .tabList {
 | 
				
			||||||
    flex-direction: column;
 | 
					    flex-direction: column;
 | 
				
			||||||
    margin-bottom: 0;
 | 
					    margin-bottom: 0;
 | 
				
			||||||
 | 
					    gap: 0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .tabPanel {
 | 
					  .tabPanel {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue