Convert media view model classes to interfaces

Timo and I agreed previously that we should ditch the class pattern for view models and instead have them be interfaces which are simply created by functions. They're more straightforward to write, mock, and instantiate this way.

The code for media view models and media items is pretty much the last remaining instance of the class pattern. Since I was about to introduce a new media view model for ringing, I wanted to get this refactor out of the way first rather than add to the technical debt.

This refactor also makes things a little easier for https://github.com/element-hq/element-call/pull/3747 by extracting volume controls into their own module.
This commit is contained in:
Robin
2026-02-25 14:02:59 +01:00
parent bc238778ad
commit 6995388a29
14 changed files with 862 additions and 914 deletions

View File

@@ -41,7 +41,7 @@ import { useObservableEagerState } from "observable-hooks";
import styles from "./GridTile.module.css";
import {
type UserMediaViewModel,
LocalUserMediaViewModel,
type LocalUserMediaViewModel,
type RemoteUserMediaViewModel,
} from "../state/MediaViewModel";
import { Slider } from "../Slider";
@@ -68,7 +68,7 @@ interface TileProps {
interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel;
mirror: boolean;
locallyMuted: boolean;
playbackMuted: boolean;
waitingForMedia?: boolean;
primaryButton?: ReactNode;
menuStart?: ReactNode;
@@ -79,7 +79,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
ref,
vm,
showSpeakingIndicators,
locallyMuted,
playbackMuted,
waitingForMedia,
primaryButton,
menuStart,
@@ -109,7 +109,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
vm.toggleCropVideo();
},
[vm],
);
@@ -117,12 +117,12 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
const handRaised = useBehavior(vm.handRaised$);
const reaction = useBehavior(vm.reaction$);
const AudioIcon = locallyMuted
const AudioIcon = playbackMuted
? VolumeOffSolidIcon
: audioEnabled
? MicOnSolidIcon
: MicOffSolidIcon;
const audioIconLabel = locallyMuted
const audioIconLabel = playbackMuted
? t("video_tile.muted_for_me")
: audioEnabled
? t("microphone_on")
@@ -166,7 +166,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
width={20}
height={20}
aria-label={audioIconLabel}
data-muted={locallyMuted || !audioEnabled}
data-muted={playbackMuted || !audioEnabled}
className={styles.muteIcon}
/>
}
@@ -245,7 +245,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
<UserMediaTile
ref={ref}
vm={vm}
locallyMuted={false}
playbackMuted={false}
mirror={mirror}
primaryButton={
switchCamera === null ? undefined : (
@@ -295,36 +295,31 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
}) => {
const { t } = useTranslation();
const waitingForMedia = useBehavior(vm.waitingForMedia$);
const locallyMuted = useBehavior(vm.locallyMuted$);
const localVolume = useBehavior(vm.localVolume$);
const playbackMuted = useBehavior(vm.playbackMuted$);
const playbackVolume = useBehavior(vm.playbackVolume$);
const onSelectMute = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleLocallyMuted();
vm.togglePlaybackMuted();
},
[vm],
);
const onChangeLocalVolume = useCallback(
(v: number) => vm.setLocalVolume(v),
[vm],
);
const onCommitLocalVolume = useCallback(() => vm.commitLocalVolume(), [vm]);
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon;
return (
<UserMediaTile
ref={ref}
vm={vm}
waitingForMedia={waitingForMedia}
locallyMuted={locallyMuted}
playbackMuted={playbackMuted}
mirror={false}
menuStart={
<>
<ToggleMenuItem
Icon={MicOffIcon}
label={t("video_tile.mute_for_me")}
checked={locallyMuted}
checked={playbackMuted}
onSelect={onSelectMute}
/>
{/* TODO: Figure out how to make this slider keyboard accessible */}
@@ -332,9 +327,9 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
<Slider
className={styles.volumeSlider}
label={t("video_tile.volume")}
value={localVolume}
onValueChange={onChangeLocalVolume}
onValueCommit={onCommitLocalVolume}
value={playbackVolume}
onValueChange={vm.adjustPlaybackVolume}
onValueCommit={vm.commitPlaybackVolume}
min={0}
max={1}
step={0.01}
@@ -374,7 +369,7 @@ export const GridTile: FC<GridTileProps> = ({
const displayName = useBehavior(media.displayName$);
const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$);
if (media instanceof LocalUserMediaViewModel) {
if (media.local) {
return (
<LocalUserMediaTile
ref={ref}