From d85cf5f929411922b6a2aeefb70ce93a78bc98bc Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 8 Sep 2025 14:21:38 +0200 Subject: [PATCH 1/5] Fix the reconnect button After a membership manager error, clicking the 'reconnect' button did nothing. This is because we were forgetting to clear the external error state, causing it to transition directly back to the same error state. --- src/room/GroupCallErrorBoundary.tsx | 10 ++++++---- src/room/GroupCallView.test.tsx | 17 +++++++++++++++-- src/room/GroupCallView.tsx | 5 +++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 3d55d005..72958683 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -36,7 +36,9 @@ import { type WidgetHelpers } from "../widget.ts"; export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; -export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void; +export type RecoveryActionHandler = ( + action: CallErrorRecoveryAction, +) => Promise; interface ErrorPageProps { error: ElementCallError; @@ -71,7 +73,7 @@ const ErrorPage: FC = ({ if (error instanceof ConnectionLostError) { actions.push({ label: t("call_ended_view.reconnect_button"), - onClick: () => recoveryActionHandler("reconnect"), + onClick: () => void recoveryActionHandler("reconnect"), }); } @@ -131,9 +133,9 @@ export const GroupCallErrorBoundary = ({ widget={widget ?? null} error={callError} resetError={resetError} - recoveryActionHandler={(action: CallErrorRecoveryAction) => { + recoveryActionHandler={async (action: CallErrorRecoveryAction) => { + await recoveryActionHandler(action); resetError(); - recoveryActionHandler(action); }} /> ); diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 12dfdf61..a1aa9452 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -15,11 +15,14 @@ import { } from "vitest"; import { render, waitFor, screen } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; -import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; +import { + MatrixRTCSessionEvent, + type MatrixRTCSession, +} from "matrix-js-sdk/lib/matrixrtc"; import { BrowserRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; -import { useState } from "react"; +import { act, useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; import { type MuteStates } from "./MuteStates"; @@ -258,3 +261,13 @@ test("GroupCallView shows errors that occur during joining", async () => { await user.click(screen.getByRole("button", { name: "Join call" })); screen.getByText("Call is not supported"); }); + +test("user can reconnect after a membership manager error", async () => { + const user = userEvent.setup(); + const { rtcSession } = createGroupCallView(null, true); + await act(() => + rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), + ); + await user.click(screen.getByRole("button", { name: "Reconnect" })); + await waitFor(() => screen.getByRole("button", { name: "Leave" })); +}); diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 63fc942f..dd6db229 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -502,10 +502,11 @@ export const GroupCallView: FC = ({ return ( { + recoveryActionHandler={async (action) => { + setExternalError(null); if (action == "reconnect") { setLeft(false); - enterRTCSessionOrError(rtcSession).catch((e) => { + await enterRTCSessionOrError(rtcSession).catch((e) => { logger.error("Error re-entering RTC session", e); }); } From db04cbfbfcaef5dfbf1035adc2c954a1901f6c82 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 8 Sep 2025 14:58:46 +0200 Subject: [PATCH 2/5] Fix type error --- src/room/GroupCallErrorBoundary.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/room/GroupCallErrorBoundary.test.tsx b/src/room/GroupCallErrorBoundary.test.tsx index f2a10bc2..51912956 100644 --- a/src/room/GroupCallErrorBoundary.test.tsx +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -132,9 +132,10 @@ test("ConnectionLostError: Action handling should reset error state", async () = const WrapComponent = (): ReactNode => { const [failState, setFailState] = useState(true); const reconnectCallback = useCallback( - (action: CallErrorRecoveryAction) => { + async (action: CallErrorRecoveryAction) => { reconnectCallbackSpy(action); setFailState(false); + return Promise.resolve(); }, [setFailState], ); From 34a8977dd15689fc4919901f334a8622f2e4cce3 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 8 Sep 2025 15:24:46 +0200 Subject: [PATCH 3/5] Increase timeout to hopefully avoid test flakes --- src/room/GroupCallView.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index a1aa9452..247f4461 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -269,5 +269,8 @@ test("user can reconnect after a membership manager error", async () => { rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), ); await user.click(screen.getByRole("button", { name: "Reconnect" })); - await waitFor(() => screen.getByRole("button", { name: "Leave" })); + // In-call controls should be visible again + await waitFor(() => screen.getByRole("button", { name: "Leave" }), { + timeout: 3000, + }); }); From 32cb8541f4c688303b567c154c5123e1fbf8c86e Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 10 Sep 2025 16:42:09 +0200 Subject: [PATCH 4/5] Actually fix the test flake --- src/room/GroupCallView.test.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 247f4461..ff2b89d5 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -13,7 +13,7 @@ import { test, vi, } from "vitest"; -import { render, waitFor, screen } from "@testing-library/react"; +import { render, waitFor, screen, act } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { MatrixRTCSessionEvent, @@ -22,7 +22,7 @@ import { import { BrowserRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; -import { act, useState } from "react"; +import { useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; import { type MuteStates } from "./MuteStates"; @@ -268,9 +268,12 @@ test("user can reconnect after a membership manager error", async () => { await act(() => rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), ); - await user.click(screen.getByRole("button", { name: "Reconnect" })); + // XXX: Wrapping the following click in act() shouldn't be necessary (the + // async state update should be processed automatically by the waitFor call), + // and yet here we are. + await act(async () => + user.click(screen.getByRole("button", { name: "Reconnect" })), + ); // In-call controls should be visible again - await waitFor(() => screen.getByRole("button", { name: "Leave" }), { - timeout: 3000, - }); + await waitFor(() => screen.getByRole("button", { name: "Leave" })); }); From 76465d0e631c35518d2411dc4be67abf71c45354 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:58:16 +0200 Subject: [PATCH 5/5] Add "wait for pickup" overlay with sound. Leave on decline/timeout (#3489) * add wait for pickup overlay Signed-off-by: Timo K * refactor and leave logic Signed-off-by: Timo K * recursive play sound logic Signed-off-by: Timo K * review Signed-off-by: Timo K * text color Signed-off-by: Timo K * overlay styling and interval fixes Signed-off-by: Timo K * fix permissions and styling Signed-off-by: Timo K * fix always getting pickup sound Signed-off-by: Timo K --------- Signed-off-by: Timo K --- src/room/InCallView.tsx | 101 +++++++++++++++++++++++++++++ src/room/WaitingForJoin.module.css | 48 ++++++++++++++ src/widget.ts | 1 + 3 files changed, 150 insertions(+) create mode 100644 src/room/WaitingForJoin.module.css diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e87072d3..c1244a2a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -50,6 +50,7 @@ import { type HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { ElementWidgetActions, widget } from "../widget"; import styles from "./InCallView.module.css"; +import overlayStyles from "../Overlay.module.css"; import { GridTile } from "../tile/GridTile"; import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -112,6 +113,15 @@ import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; import { Toast } from "../Toast.tsx"; +import { Avatar, Size as AvatarSize } from "../Avatar"; +import waitingStyles from "./WaitingForJoin.module.css"; +import { prefetchSounds } from "../soundUtils"; +import { useAudioContext } from "../useAudioContext"; +// TODO: Dont use this!!! use the correct sound +import genericSoundOgg from "../sound/reactions/generic.ogg?url"; +import genericSoundMp3 from "../sound/reactions/generic.mp3?url"; +import leftCallSoundMp3 from "../sound/left_call.mp3"; +import leftCallSoundOgg from "../sound/left_call.ogg"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -265,6 +275,26 @@ export const InCallView: FC = ({ }); const muteAllAudio = useBehavior(muteAllAudio$); + // Call pickup state and display names are needed for waiting overlay/sounds + const callPickupState = useBehavior(vm.callPickupState$); + + // Preload a waiting and decline sounds + const pickupPhaseSoundCache = useInitial(async () => { + return prefetchSounds({ + waiting: { mp3: genericSoundMp3, ogg: genericSoundOgg }, + decline: { mp3: leftCallSoundMp3, ogg: leftCallSoundOgg }, + // Do we want a timeout sound? + }); + }); + // configure this to sth that fits to the pickup waiting sound. + // 1600 is in sync with the animation. + const PICKUP_SOUND_INTERVAL = 1600; + + const pickupPhaseAudio = useAudioContext({ + sounds: pickupPhaseSoundCache, + latencyHint: "interactive", + muted: muteAllAudio, + }); // This seems like it might be enough logic to use move it into the call view model? const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); @@ -328,6 +358,76 @@ export const InCallView: FC = ({ const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); useSubscription(vm.autoLeave$, onLeave); + // When we enter timeout or decline we will leave the call. + useEffect((): void | (() => void) => { + if (callPickupState === "timeout") { + onLeave(); + } + if (callPickupState === "decline") { + // Wait for the sound to finish before leaving + void pickupPhaseAudio + ?.playSound("decline") + .catch((e) => { + logger.error("Failed to play decline sound", e); + }) + .finally(() => { + onLeave(); + }); + } + }, [callPickupState, onLeave, pickupPhaseAudio]); + + // When waiting for pickup, loop a waiting sound + useEffect((): void | (() => void) => { + if (callPickupState !== "ringing") return; + const interval = window.setInterval(() => { + void pickupPhaseAudio?.playSound("waiting"); + }, PICKUP_SOUND_INTERVAL); + return (): void => window.clearInterval(interval); + }, [callPickupState, pickupPhaseAudio]); + + // Waiting UI overlay + const waitingOverlay: JSX.Element | null = useMemo(() => { + // No overlay if not in ringing state + if (callPickupState !== "ringing") return null; + + // Use room state for other participants data (the one that we likely want to reach) + const roomOthers = [ + ...matrixRoom.getMembersWithMembership("join"), + ...matrixRoom.getMembersWithMembership("invite"), + ].filter((m) => m.userId !== client.getUserId()); + // Yield if there are not other members in the room. + if (roomOthers.length === 0) return null; + + const otherMember = roomOthers.length > 0 ? roomOthers[0] : undefined; + const isOneOnOne = roomOthers.length === 1 && otherMember; + const text = isOneOnOne + ? `Waiting for ${otherMember.name ?? otherMember.userId} to join…` + : "Waiting for other participants…"; + const avatarMxc = isOneOnOne + ? (otherMember.getMxcAvatarUrl?.() ?? undefined) + : (matrixRoom.getMxcAvatarUrl() ?? undefined); + + return ( +
+
+
+ +
+ + {text} + +
+
+ ); + }, [callPickupState, client, matrixRoom]); + // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported // in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility @@ -806,6 +906,7 @@ export const InCallView: FC = ({ onBackToVideoPressed={audioOutputSwitcher?.switch} /> + {waitingOverlay} {footer} {layout.type !== "pip" && ( <> diff --git a/src/room/WaitingForJoin.module.css b/src/room/WaitingForJoin.module.css new file mode 100644 index 00000000..8f7ebeec --- /dev/null +++ b/src/room/WaitingForJoin.module.css @@ -0,0 +1,48 @@ +.overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.pulse { + position: relative; + height: 90px; +} + +.pulse::before { + content: ""; + position: absolute; + inset: -12px; + border-radius: 9999px; + border: 12px solid rgba(255, 255, 255, 0.6); + animation: pulse 1.6s ease-out infinite; +} + +.text { + color: var(--cpd-color-text-on-solid-primary); +} + +@keyframes pulse { + 0% { + transform: scale(0.95); + opacity: 0.7; + } + 70% { + transform: scale(1.15); + opacity: 0.15; + } + 100% { + transform: scale(1.2); + opacity: 0; + } +} diff --git a/src/widget.ts b/src/widget.ts index 61bf3850..60163c7c 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -116,6 +116,7 @@ export const widget = ((): WidgetHelpers | null => { EventType.Reaction, EventType.RoomRedaction, ElementCallReactionEventType, + EventType.RTCDecline, ]; const sendState = [