Merge branch 'robin/switch-camera-tile' into robin/reactions-small

This commit is contained in:
Robin
2025-08-14 16:39:51 +02:00
80 changed files with 2782 additions and 1783 deletions

View File

@@ -60,7 +60,7 @@ export function CallEventAudioRenderer({
const audioEngineRef = useLatest(audioEngineCtx);
useEffect(() => {
const joinSub = vm.memberChanges$
const joinSub = vm.participantChanges$
.pipe(
filter(
({ joined, ids }) =>
@@ -72,7 +72,7 @@ export function CallEventAudioRenderer({
void audioEngineRef.current?.playSound("join");
});
const leftSub = vm.memberChanges$
const leftSub = vm.participantChanges$
.pipe(
filter(
({ ids, left }) =>

View File

@@ -61,3 +61,7 @@
.overlay > p {
text-align: center;
}
.spacer {
min-height: var(--cpd-space-32x);
}

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC } from "react";
import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web";
import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import styles from "./EarpieceOverlay.module.css";
@@ -22,12 +22,12 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
return (
<div className={styles.overlay} data-show={show}>
<BigIcon className={styles.icon}>
<EarpieceIcon aria-hidden />
<VoiceCallIcon aria-hidden />
</BigIcon>
<Heading as="h2" weight="semibold" size="md">
{t("earpiece.overlay_title")}
{t("handset.overlay_title")}
</Heading>
<Text>{t("earpiece.overlay_description")}</Text>
<Text>{t("handset.overlay_description")}</Text>
<Button
kind="primary"
size="sm"
@@ -35,8 +35,10 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
onBackToVideoPressed?.();
}}
>
{t("earpiece.overlay_back_button")}
{t("handset.overlay_back_button")}
</Button>
{/* This spacer is used to give the overlay an offset to the top. */}
<div className={styles.spacer} />
</div>
);
};

View File

@@ -21,6 +21,7 @@ import {
OfflineIcon,
WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";
import {
ConnectionLostError,
@@ -93,9 +94,13 @@ const ErrorPage: FC<ErrorPageProps> = ({
</p>
{actions &&
actions.map((action, index) => (
<button onClick={action.onClick} key={`action${index}`}>
<Button
kind="secondary"
onClick={action.onClick}
key={`action${index}`}
>
{action.label}
</button>
</Button>
))}
</ErrorView>
</FullScreenView>

View File

@@ -16,7 +16,6 @@ import {
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 { of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
@@ -43,6 +42,7 @@ import { MatrixRTCFocusMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
import { constant } from "../state/Behavior";
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
@@ -141,7 +141,7 @@ function createGroupCallView(
room,
localRtcMember,
[],
).withMemberships(of([]));
).withMemberships(constant([]));
rtcSession.joined = joined;
const muteState = {
audio: { enabled: false },

View File

@@ -24,7 +24,6 @@ import {
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { useNavigate } from "react-router-dom";
import { useObservableEagerState } from "observable-hooks";
import type { IWidgetApiRequest } from "matrix-widget-api";
import {
@@ -72,6 +71,7 @@ import {
import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
declare global {
interface Window {
@@ -110,7 +110,7 @@ export const GroupCallView: FC<Props> = ({
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const muteAllAudio = useObservableEagerState(muteAllAudio$);
const muteAllAudio = useBehavior(muteAllAudio$);
const leaveSoundContext = useLatest(
useAudioContext({
sounds: callEventAudioSounds,
@@ -166,7 +166,11 @@ export const GroupCallView: FC<Props> = ({
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const { perParticipantE2EE, returnToLobby } = useUrlParams();
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
const [useExperimentalToDeviceTransport] = useSetting(
@@ -174,7 +178,6 @@ export const GroupCallView: FC<Props> = ({
);
// Save the password once we start the groupCallView
const { password: passwordFromUrl } = useUrlParams();
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);

View File

@@ -25,11 +25,11 @@ import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { useObservable, useSubscription } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
EarpieceIcon,
VoiceCallSolidIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
@@ -110,6 +110,7 @@ import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMembership
import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -137,17 +138,17 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => {
logger.info(
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
);
return (): void => {
logger.info(
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
);
livekitRoom
?.disconnect()
.then(() => {
logger.info(
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
`[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
);
})
.catch((e) => {
@@ -156,6 +157,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft } = useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
const reactionsReader = new ReactionsReader(props.rtcSession);
@@ -163,7 +166,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.rtcSession,
livekitRoom,
mediaDevices,
props.e2eeSystem,
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
},
connStateObservable$,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
@@ -180,6 +186,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices,
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -249,7 +256,7 @@ export const InCallView: FC<InCallViewProps> = ({
room: livekitRoom,
});
const muteAllAudio = useObservableEagerState(muteAllAudio$);
const muteAllAudio = useBehavior(muteAllAudio$);
// This seems like it might be enough logic to use move it into the call view model?
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
@@ -300,15 +307,16 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);
const windowMode = useObservableEagerState(vm.windowMode$);
const layout = useObservableEagerState(vm.layout$);
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$);
const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const gridMode = useObservableEagerState(vm.gridMode$);
const showHeader = useObservableEagerState(vm.showHeader$);
const showFooter = useObservableEagerState(vm.showFooter$);
const earpieceMode = useObservableEagerState(vm.earpieceMode$);
const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$);
const gridMode = useBehavior(vm.gridMode$);
const showHeader = useBehavior(vm.showHeader$);
const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);
// 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
@@ -454,9 +462,9 @@ export const InCallView: FC<InCallViewProps> = ({
useMemo(() => {
if (audioOutputSwitcher === null) return null;
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon;
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
const label = isEarpieceTarget
? t("settings.devices.earpiece")
? t("settings.devices.handset")
: t("settings.devices.loudspeaker");
return (
@@ -524,16 +532,12 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight,
model,
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
const spotlightExpanded = useObservableEagerState(
vm.spotlightExpanded$,
);
const onToggleExpanded = useObservableEagerState(
vm.toggleSpotlightExpanded$,
);
const showSpeakingIndicatorsValue = useObservableEagerState(
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
const showSpeakingIndicatorsValue = useBehavior(
vm.showSpeakingIndicators$,
);
const showSpotlightIndicatorsValue = useObservableEagerState(
const showSpotlightIndicatorsValue = useBehavior(
vm.showSpotlightIndicators$,
);

View File

@@ -191,7 +191,11 @@ describe("useMuteStates", () => {
mockConfig();
render(
<MemoryRouter initialEntries={["/room/?skipLobby=true"]}>
<MemoryRouter
initialEntries={[
"/room/?skipLobby=true&widgetId=1234&parentUrl=www.parent.org",
]}
>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>

View File

@@ -86,6 +86,14 @@ export function useMuteStates(isJoined: boolean): MuteStates {
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
});
useEffect(() => {
// If audio is enabled, we need to request the device names again,
// because iOS will not be able to switch to the correct device after un-muting.
// This is one of the main changes that makes iOS work with bluetooth audio devices.
if (audio.enabled) {
devices.requestDeviceNames();
}
}, [audio.enabled, devices]);
const isEarpiece = useIsEarpiece();
const video = useMuteState(
devices.videoInput,

View File

@@ -6,16 +6,16 @@ Please see LICENSE in the repository root for full details.
*/
import { type ReactNode } from "react";
import { useObservableState } from "observable-hooks";
import styles from "./ReactionsOverlay.module.css";
import { type CallViewModel } from "../state/CallViewModel";
import { useBehavior } from "../useBehavior";
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {
const reactionsIcons = useObservableState(vm.visibleReactions$);
const reactionsIcons = useBehavior(vm.visibleReactions$);
return (
<div className={styles.container}>
{reactionsIcons?.map(({ sender, emoji, startX }) => (
{reactionsIcons.map(({ sender, emoji, startX }) => (
<span
// Reactions effects are considered presentation elements. The reaction
// is also present on the sender's tile, which assistive technology can

View File

@@ -132,7 +132,13 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
<p>
You were disconnected from the call.
</p>
<button>
<button
class="_button_vczzf_8"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
Reconnect
</button>
<button
@@ -742,7 +748,13 @@ exports[`should report correct error for 'Connection lost' 1`] = `
<p>
You were disconnected from the call.
</p>
<button>
<button
class="_button_vczzf_8"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
Reconnect
</button>
<button

View File

@@ -98,18 +98,30 @@ exports[`InCallView > rendering > renders 1`] = `
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2c3.93 0 7 3.07 7 7a1 1 0 0 1-2 0c0-2.8-2.2-5-5-5S9 6.2 9 9c0 .93.29 1.98.82 2.94.71 1.29 1.53 1.92 2.32 2.53.92.71 1.88 1.44 2.39 3 .5 1.5 1 2.01 1.71 2.38.2.09.47.15.76.15 1.1 0 2-.9 2-2a1 1 0 1 1 2 0 4 4 0 0 1-5.64 3.65c-1.36-.71-2.13-1.73-2.73-3.55-.32-.98-.9-1.43-1.71-2.05-.87-.67-1.94-1.5-2.85-3.15C7.38 11.65 7 10.26 7 9c0-3.93 3.07-7 7-7"
/>
<path
d="M6.145 1.3a1 1 0 0 1 1.427 1.4A8.97 8.97 0 0 0 5 9c0 2.3.862 4.397 2.281 5.988l.291.312.069.077A1 1 0 0 1 6.22 16.77l-.075-.07-.356-.38A10.96 10.96 0 0 1 3 9c0-2.998 1.2-5.717 3.145-7.7M14 6.5a2.5 2.5 0 0 1 0 5 2.501 2.501 0 0 1 0-5"
/>
<g
clip-path="url(#a)"
>
<path
clip-rule="evenodd"
d="M8.929 15.1a13.6 13.6 0 0 0 4.654 3.066q2.62 1.036 5.492.923h.008l.003-.004.003-.002-.034-3.124-3.52-.483-1.791 1.792-.645-.322a13.5 13.5 0 0 1-3.496-2.52 13.4 13.4 0 0 1-2.52-3.496l-.322-.644 1.792-1.792-.483-3.519-3.123-.034-.003.002-.003.004v.002a13.65 13.65 0 0 0 .932 5.492A13.4 13.4 0 0 0 8.93 15.1m3.92 4.926a15.6 15.6 0 0 1-5.334-3.511 15.4 15.4 0 0 1-3.505-5.346 15.6 15.6 0 0 1-1.069-6.274 1.93 1.93 0 0 1 .589-1.366c.366-.366.84-.589 1.386-.589h.01l3.163.035a1.96 1.96 0 0 1 1.958 1.694v.005l.487 3.545v.003c.043.297.025.605-.076.907a2 2 0 0 1-.485.773l-.762.762a11.4 11.4 0 0 0 3.206 3.54q.457.33.948.614l.762-.761a2 2 0 0 1 .774-.486c.302-.1.61-.118.907-.076l3.553.487a1.96 1.96 0 0 1 1.694 1.958l.034 3.174c0 .546-.223 1.02-.588 1.386-.361.36-.827.582-1.363.588a15.3 15.3 0 0 1-6.29-1.062"
fill-rule="evenodd"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 0h24v24H0z"
/>
</clippath>
</defs>
</svg>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Earpiece Mode
Handset Mode
</h2>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
@@ -125,6 +137,9 @@ exports[`InCallView > rendering > renders 1`] = `
>
Back to Speaker Mode
</button>
<div
class="spacer"
/>
</div>
<div
class="container"