diff --git a/locales/en/app.json b/locales/en/app.json index d375b629..007e372a 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -55,6 +55,7 @@ "profile": "Profile", "reaction": "Reaction", "reactions": "Reactions", + "reconnecting": "Reconnecting…", "settings": "Settings", "unencrypted": "Not encrypted", "username": "Username", diff --git a/src/App.tsx b/src/App.tsx index 6d7d1e1e..b87f587c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,6 @@ import { RegisterPage } from "./auth/RegisterPage"; import { RoomPage } from "./room/RoomPage"; import { ClientProvider } from "./ClientContext"; import { ErrorPage, LoadingPage } from "./FullScreenView"; -import { DisconnectedBanner } from "./DisconnectedBanner"; import { Initializer } from "./initializer"; import { widget } from "./widget"; import { useTheme } from "./useTheme"; @@ -86,7 +85,6 @@ export const App: FC = ({ vm }) => { } > - } /> } /> diff --git a/src/AppBar.tsx b/src/AppBar.tsx index e70bb50d..aaa7565e 100644 --- a/src/AppBar.tsx +++ b/src/AppBar.tsx @@ -61,7 +61,11 @@ export const AppBar: FC = ({ children }) => { style={{ display: hidden ? "none" : "block" }} className={styles.bar} > -
+
diff --git a/src/Header.tsx b/src/Header.tsx index 577410f8..cffc3402 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -17,27 +17,38 @@ import Logo from "./icons/Logo.svg?react"; import { Avatar, Size } from "./Avatar"; import { EncryptionLock } from "./room/EncryptionLock"; import { useMediaQuery } from "./useMediaQuery"; +import { DisconnectedBanner } from "./DisconnectedBanner"; interface HeaderProps extends HTMLAttributes { ref?: Ref; children: ReactNode; className?: string; + /** + * Whether the header should display an informational banner whenever the + * client is disconnected from the homeserver. + * @default true + */ + disconnectedBanner?: boolean; } export const Header: FC = ({ ref, children, className, + disconnectedBanner = true, ...rest }) => { return ( -
- {children} -
+ <> +
+ {children} +
+ {disconnectedBanner && } + ); }; diff --git a/src/Toast.tsx b/src/Toast.tsx index ada5b29c..105572c8 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -45,6 +45,12 @@ interface Props { * A supporting icon to display within the toast. */ Icon?: ComponentType>; + /** + * Whether the toast should be portaled into the root of the document (rather + * than rendered in-place within the component tree). + * @default true + */ + portal?: boolean; } /** @@ -56,6 +62,7 @@ export const Toast: FC = ({ autoDismiss, children, Icon, + portal = true, }) => { const onOpenChange = useCallback( (open: boolean) => { @@ -71,29 +78,33 @@ export const Toast: FC = ({ } }, [open, autoDismiss, onDismiss]); + const content = ( + <> + + + + + + {children} + + + {Icon && } + + + + ); + return ( - - - - - - - {children} - - - {Icon && } - - - + {portal ? {content} : content} ); }; diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 281bbafd..1c515175 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -31,6 +31,7 @@ import { aliceRtcMember, bobRtcMember, local, + localRtcMember, } from "../utils/test-fixtures"; vitest.mock("../useAudioContext"); @@ -66,7 +67,7 @@ beforeEach(() => { * a noise every time. */ test("plays one sound when entering a call", () => { - const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([ + const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([ local, alice, ]); @@ -74,56 +75,58 @@ test("plays one sound when entering a call", () => { // Joining a call usually means remote participants are added later. act(() => { - remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]); + rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]); }); expect(playSound).toHaveBeenCalledOnce(); }); test("plays a sound when a user joins", () => { - const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([ + const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([ local, alice, ]); render(); act(() => { - remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]); + rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]); }); // Play a sound when joining a call. expect(playSound).toBeCalledWith("join"); }); test("plays a sound when a user leaves", () => { - const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([ + const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([ local, alice, ]); render(); act(() => { - remoteRtcMemberships$.next([]); + rtcMemberships$.next([localRtcMember]); }); expect(playSound).toBeCalledWith("left"); }); test("plays no sound when the participant list is more than the maximum size", () => { - const mockRtcMemberships: CallMembership[] = []; + const mockRtcMemberships: CallMembership[] = [localRtcMember]; for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) { mockRtcMemberships.push( mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`), ); } - const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment( + const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment( [local, alice], mockRtcMemberships, ); render(); expect(playSound).not.toBeCalled(); + // Remove the last membership in the array to test the leaving sound + // (The array has length MAX_PARTICIPANT_COUNT_FOR_SOUND + 1) act(() => { - remoteRtcMemberships$.next( - mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1), + rtcMemberships$.next( + mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND), ); }); expect(playSound).toBeCalledWith("left"); diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 4eb32af0..12dfdf61 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -137,11 +137,9 @@ function createGroupCallView( getJoinRule: () => JoinRule.Invite, } as Partial as RoomState, }); - const rtcSession = new MockRTCSession( - room, - localRtcMember, - [], - ).withMemberships(constant([])); + const rtcSession = new MockRTCSession(room, []).withMemberships( + constant([localRtcMember]), + ); rtcSession.joined = joined; const muteState = { audio: { enabled: false }, diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 76352523..ea57bd10 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -452,6 +452,7 @@ export const GroupCallView: FC = ({ client={client} matrixInfo={matrixInfo} rtcSession={rtcSession as MatrixRTCSession} + matrixRoom={room} participantCount={participantCount} onLeave={onLeave} header={header} diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index b88aaad7..ec057e94 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -175,6 +175,7 @@ function createInCallView(): RenderResult & { kind: E2eeType.NONE, }, }} + matrixRoom={room} livekitRoom={livekitRoom} participantCount={0} onLeave={function (): void { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 9cd5ffb2..4e3229a5 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details. import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import { IconButton, Text, Tooltip } from "@vector-im/compound-web"; -import { ConnectionState, type Room } from "livekit-client"; -import { type MatrixClient } from "matrix-js-sdk"; +import { ConnectionState, type Room as LivekitRoom } from "livekit-client"; +import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk"; import { type FC, type PointerEvent, @@ -111,6 +111,7 @@ import { useMediaDevices } from "../MediaDevicesContext.ts"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; +import { Toast } from "../Toast.tsx"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -164,6 +165,7 @@ export const ActiveCall: FC = (props) => { const reactionsReader = new ReactionsReader(props.rtcSession); const vm = new CallViewModel( props.rtcSession, + props.matrixRoom, livekitRoom, mediaDevices, { @@ -182,6 +184,7 @@ export const ActiveCall: FC = (props) => { } }, [ props.rtcSession, + props.matrixRoom, livekitRoom, mediaDevices, props.e2eeSystem, @@ -210,7 +213,8 @@ export interface InCallViewProps { vm: CallViewModel; matrixInfo: MatrixInfo; rtcSession: MatrixRTCSession; - livekitRoom: Room; + matrixRoom: MatrixRoom; + livekitRoom: LivekitRoom; muteStates: MuteStates; participantCount: number; /** Function to call when the user explicitly ends the call */ @@ -226,6 +230,7 @@ export const InCallView: FC = ({ vm, matrixInfo, rtcSession, + matrixRoom, livekitRoom, muteStates, participantCount, @@ -270,7 +275,7 @@ export const InCallView: FC = ({ const [useExperimentalToDeviceTransport] = useSetting( useExperimentalToDeviceTransportSetting, ); - const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId); + const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId); const memberships = useMatrixRTCSessionMemberships(rtcSession); const showToDeviceEncryption = useMemo( @@ -307,6 +312,7 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); + const reconnecting = useBehavior(vm.reconnecting$); const windowMode = useBehavior(vm.windowMode$); const layout = useBehavior(vm.layout$); const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); @@ -499,7 +505,11 @@ export const InCallView: FC = ({ break; case "standard": header = ( -
+
= ({ }; const rageshakeRequestModalProps = useRageshakeRequestModal( - rtcSession.room.roomId, + matrixRoom.roomId, ); const toggleScreensharing = useCallback(() => { @@ -750,6 +760,9 @@ export const InCallView: FC = ({ ); + // The reconnecting toast cannot be dismissed + const onDismissReconnectingToast = useCallback(() => {}, []); + return (
= ({ {renderContent()} + + {t("common.reconnecting")} + @@ -788,7 +808,7 @@ export const InCallView: FC = ({ rendering > renders 1`] = ` >