Settings improvements

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2023-05-05 11:44:35 +02:00
parent 515e00b763
commit 0269753f59
No known key found for this signature in database
GPG key ID: D1D45825D60C24D2
15 changed files with 380 additions and 470 deletions

View file

@ -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,32 +56,34 @@ export default function App({ history }: AppProps) {
{loaded ? ( {loaded ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<ClientProvider> <ClientProvider>
<InspectorContextProvider> <MediaHandlerProvider>
<Sentry.ErrorBoundary fallback={errorPage}> <InspectorContextProvider>
<OverlayProvider> <Sentry.ErrorBoundary fallback={errorPage}>
<Switch> <OverlayProvider>
<SentryRoute exact path="/"> <Switch>
<HomePage /> <SentryRoute exact path="/">
</SentryRoute> <HomePage />
<SentryRoute exact path="/login"> </SentryRoute>
<LoginPage /> <SentryRoute exact path="/login">
</SentryRoute> <LoginPage />
<SentryRoute exact path="/register"> </SentryRoute>
<RegisterPage /> <SentryRoute exact path="/register">
</SentryRoute> <RegisterPage />
<SentryRoute path="/room/:roomId?"> </SentryRoute>
<RoomPage /> <SentryRoute path="/room/:roomId?">
</SentryRoute> <RoomPage />
<SentryRoute path="/inspector"> </SentryRoute>
<SequenceDiagramViewerPage /> <SentryRoute path="/inspector">
</SentryRoute> <SequenceDiagramViewerPage />
<SentryRoute path="*"> </SentryRoute>
<RoomRedirect /> <SentryRoute path="*">
</SentryRoute> <RoomRedirect />
</Switch> </SentryRoute>
</OverlayProvider> </Switch>
</Sentry.ErrorBoundary> </OverlayProvider>
</InspectorContextProvider> </Sentry.ErrorBoundary>
</InspectorContextProvider>
</MediaHandlerProvider>
</ClientProvider> </ClientProvider>
</Suspense> </Suspense>
) : ( ) : (

View file

@ -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";
@ -59,6 +60,11 @@ export function UserMenu({
icon: UserIcon, icon: UserIcon,
label: displayName, label: displayName,
}); });
arr.push({
key: "settings",
icon: SettingsIcon,
label: t("Settings"),
});
if (isPasswordlessUser && !preventNavigation) { if (isPasswordlessUser && !preventNavigation) {
arr.push({ arr.push({

View file

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

View file

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

View file

@ -1,114 +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, useEffect } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import {
useSubmitRageshake,
useRageshakeRequest,
} from "../settings/submit-rageshake";
import { Body } from "../typography/Typography";
interface Props {
inCall: boolean;
roomId: string;
onClose?: () => void;
// TODO: add all props for for <Modal>
[index: string]: unknown;
}
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
const onSubmitFeedback = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const descriptionData = data.get("description");
const description =
typeof descriptionData === "string" ? descriptionData : "";
const sendLogs = Boolean(data.get("sendLogs"));
const rageshakeRequestId = randomString(16);
submitRageshake({
description,
sendLogs,
rageshakeRequestId,
roomId,
});
if (inCall && sendLogs) {
sendRageshakeRequest(roomId, rageshakeRequestId);
}
},
[inCall, submitRageshake, roomId, sendRageshakeRequest]
);
useEffect(() => {
if (sent) {
onClose();
}
}, [sent, onClose]);
return (
<Modal
title={t("Submit feedback")}
isDismissable
onClose={onClose}
{...rest}
>
<ModalContent>
<Body>{t("Having trouble? Help us fix it.")}</Body>
<form onSubmit={onSubmitFeedback}>
<FieldRow>
<InputField
id="description"
name="description"
label={t("Description (optional)")}
type="textarea"
/>
</FieldRow>
<FieldRow>
<InputField
id="sendLogs"
name="sendLogs"
label={t("Include debug logs")}
type="checkbox"
defaultChecked
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={sending}>
{sending ? t("Submitting feedback…") : t("Submit feedback")}
</Button>
</FieldRow>
</form>
</ModalContent>
</Modal>
);
}

View file

@ -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,8 @@ 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";
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 +129,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 +151,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,
@ -342,9 +341,34 @@ export function InCallView({
}; };
const { const {
modalState: rageshakeRequestModalState, modalState: settingsModalState,
modalProps: rageshakeRequestModalProps, modalProps: settingsModalProps,
} = useRageshakeRequestModal(groupCall.room.roomId); }: {
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,
@ -402,17 +426,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}
/>
);
} }
} }
@ -434,7 +448,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>
)} )}
@ -448,12 +464,16 @@ export function InCallView({
otelGroupCallMembership={otelGroupCallMembership} otelGroupCallMembership={otelGroupCallMembership}
show={showInspector} show={showInspector}
/> />
{rageshakeRequestModalState.isOpen && ( {settingsModalState.isOpen && (
<RageshakeRequestModal <SettingsModal
{...rageshakeRequestModalProps} client={client}
roomIdOrAlias={roomIdOrAlias} roomId={roomIdOrAlias}
{...settingsModalProps}
/> />
)} )}
{inviteModalState.isOpen && (
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
)}
</div> </div>
); );
} }

View file

@ -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">
<OverflowIcon />
</Button>
</TooltipTrigger>
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label={t("More menu")} onAction={onAction}>
{showInvite && (
<Item key="invite" textValue={t("Invite people")}>
<AddUserIcon />
<span>{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}
/>
)}
</>
);
}

View file

@ -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={roomIdOrAlias}
{...settingsModalProps}
/>
)}
{inviteModalState.isOpen && showControls && ( {inviteModalState.isOpen && showControls && (
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} /> <InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
)} )}

View file

@ -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,15 +100,13 @@ export const RoomPage: FC = () => {
} }
return ( return (
<MediaHandlerProvider client={client}> <GroupCallLoader
<GroupCallLoader client={client}
client={client} roomIdOrAlias={roomIdOrAlias}
roomIdOrAlias={roomIdOrAlias} viaServers={viaServers}
viaServers={viaServers} createPtt={isPtt}
createPtt={isPtt} >
> {groupCallView}
{groupCallView} </GroupCallLoader>
</GroupCallLoader>
</MediaHandlerProvider>
); );
}; };

View file

@ -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}>
@ -95,17 +108,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>
); );
} }

View file

@ -0,0 +1,93 @@
/*
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 } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { Button } from "../button";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { useSubmitRageshake, useRageshakeRequest } from "./submit-rageshake";
import { Body } from "../typography/Typography";
interface Props {
roomId?: string;
}
export function FeedbackSettingsTab({ roomId }: Props) {
const { t } = useTranslation();
const { submitRageshake, sending, error } = useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
const onSubmitFeedback = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const descriptionData = data.get("description");
const description =
typeof descriptionData === "string" ? descriptionData : "";
const sendLogs = Boolean(data.get("sendLogs"));
const rageshakeRequestId = randomString(16);
submitRageshake({
description,
sendLogs,
rageshakeRequestId,
roomId,
});
if (roomId && sendLogs) {
sendRageshakeRequest(roomId, rageshakeRequestId);
}
},
[submitRageshake, roomId, sendRageshakeRequest]
);
return (
<div>
<Body>{t("Having trouble? Help us fix it.")}</Body>
<form onSubmit={onSubmitFeedback}>
<FieldRow>
<InputField
id="description"
name="description"
label={t("Description (optional)")}
type="textarea"
/>
</FieldRow>
<FieldRow>
<InputField
id="sendLogs"
name="sendLogs"
label={t("Include debug logs")}
type="checkbox"
defaultChecked
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={sending}>
{sending ? t("Submitting feedback…") : t("Submit feedback")}
</Button>
</FieldRow>
</form>
</div>
);
}

View file

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

View file

@ -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,27 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ChangeEvent, useCallback, useEffect, useState } from "react"; import React, { ChangeEvent, useCallback, useState } from "react";
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 { Button } from "../button"; import { Button } from "../button";
import { useProfile } from "./useProfile"; import { useProfile } from "../profile/useProfile";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Modal, ModalContent } from "../Modal";
import { AvatarInputField } from "../input/AvatarInputField"; import { AvatarInputField } from "../input/AvatarInputField";
import styles from "./ProfileModal.module.css"; import styles from "./ProfileSettingsTab.module.css";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
onClose: () => void;
[rest: string]: unknown;
} }
export function ProfileModal({ client, ...rest }: Props) { export function ProfileSettingsTab({ client }: Props) {
const { onClose } = rest;
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
success,
error, error,
loading, loading,
displayName: initialDisplayName, displayName: initialDisplayName,
@ -78,64 +73,51 @@ export function ProfileModal({ client, ...rest }: Props) {
[saveProfile, removeAvatar] [saveProfile, removeAvatar]
); );
useEffect(() => {
if (success) {
onClose();
}
}, [success, onClose]);
return ( return (
<Modal title={t("Profile")} isDismissable {...rest}> <form onSubmit={onSubmit}>
<ModalContent> <FieldRow className={styles.avatarFieldRow}>
<form onSubmit={onSubmit}> <AvatarInputField
<FieldRow className={styles.avatarFieldRow}> id="avatar"
<AvatarInputField name="avatar"
id="avatar" label={t("Avatar")}
name="avatar" avatarUrl={avatarUrl}
label={t("Avatar")} displayName={displayName}
avatarUrl={avatarUrl} onRemoveAvatar={onRemoveAvatar}
displayName={displayName} />
onRemoveAvatar={onRemoveAvatar} </FieldRow>
/> <FieldRow>
</FieldRow> <InputField
<FieldRow> id="userId"
<InputField name="userId"
id="userId" label={t("User ID")}
name="userId" type="text"
label={t("User ID")} disabled
type="text" value={client.getUserId()}
disabled />
value={client.getUserId()} </FieldRow>
/> <FieldRow>
</FieldRow> <InputField
<FieldRow> id="displayName"
<InputField name="displayName"
id="displayName" label={t("Display name")}
name="displayName" type="text"
label={t("Display name")} required
type="text" autoComplete="off"
required placeholder={t("Display name")}
autoComplete="off" value={displayName}
placeholder={t("Display name")} onChange={onChangeDisplayName}
value={displayName} />
onChange={onChangeDisplayName} </FieldRow>
/> {error && (
</FieldRow> <FieldRow>
{error && ( <ErrorMessage error={error} />
<FieldRow> </FieldRow>
<ErrorMessage error={error} /> )}
</FieldRow> <FieldRow rightAlign>
)} <Button type="submit" disabled={loading}>
<FieldRow rightAlign> {loading ? t("Saving…") : t("Save")}
<Button type="button" variant="secondary" onPress={onClose}> </Button>
Cancel </FieldRow>
</Button> </form>
<Button type="submit" disabled={loading}>
{loading ? t("Saving…") : t("Save")}
</Button>
</FieldRow>
</form>
</ModalContent>
</Modal>
); );
} }

View file

@ -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;
} }
@ -68,6 +76,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>
@ -87,8 +104,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} />
@ -145,6 +167,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} />
@ -167,6 +190,29 @@ export const SettingsModal = (props: Props) => {
</SelectInput> </SelectInput>
</TabItem> </TabItem>
<TabItem <TabItem
key="profile"
title={
<>
<UserIcon width={16} height={16} />
<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} />
@ -174,18 +220,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"
@ -200,9 +238,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} />

View file

@ -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.
@ -15,23 +15,7 @@ limitations under the License.
*/ */
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* 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 { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler"; import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
import React, { import React, {
useState, useState,
@ -43,6 +27,8 @@ import React, {
ReactNode, ReactNode,
} from "react"; } from "react";
import { useClient } from "../ClientContext";
export interface MediaHandlerContextInterface { export interface MediaHandlerContextInterface {
audioInput: string; audioInput: string;
audioInputs: MediaDeviceInfo[]; audioInputs: MediaDeviceInfo[];
@ -89,10 +75,10 @@ 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,
@ -104,19 +90,21 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
}, },
setState, setState,
] = useState(() => { ] = useState(() => {
const mediaPreferences = getMediaPreferences(); const mediaHandler = client?.getMediaHandler();
const mediaHandler = client.getMediaHandler();
mediaHandler.restoreMediaSettings( if (mediaHandler) {
mediaPreferences?.audioInput, const mediaPreferences = getMediaPreferences();
mediaPreferences?.videoInput mediaHandler?.restoreMediaSettings(
); mediaPreferences?.audioInput,
mediaPreferences?.videoInput
);
}
return { return {
// @ts-ignore, ignore that audioInput is a private members of mediaHandler // @ts-ignore, ignore that audioInput is a private members of mediaHandler
audioInput: mediaHandler.audioInput, audioInput: mediaHandler?.audioInput,
// @ts-ignore, ignore that videoInput is a private members of mediaHandler // @ts-ignore, ignore that videoInput is a private members of mediaHandler
videoInput: mediaHandler.videoInput, videoInput: mediaHandler?.videoInput,
audioOutput: undefined, audioOutput: undefined,
audioInputs: [], audioInputs: [],
videoInputs: [], videoInputs: [],
@ -125,6 +113,8 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
}); });
useEffect(() => { useEffect(() => {
if (!client) return;
const mediaHandler = client.getMediaHandler(); const mediaHandler = client.getMediaHandler();
function updateDevices(): void { function updateDevices(): void {