Compare commits

..

1 Commits

Author SHA1 Message Date
Hugh Nimmo-Smith
30253b0c19 Always show all call memberships from the room state 2024-08-05 14:56:50 +01:00
25 changed files with 1609 additions and 1681 deletions

View File

@@ -51,7 +51,7 @@ jobs:
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
- name: Build and push Docker image
uses: docker/build-push-action@16ebe778df0e7752d2cfcbd924afdbbd89c1a755 # v6.6.1
uses: docker/build-push-action@5176d81f87c23d6fc96624dfdbcd9f3830bbe445 # v6.5.0
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -39,7 +39,7 @@ jobs:
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
NODE_OPTIONS: "--max-old-space-size=4096"
- name: Upload Artifact
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4
with:
name: build-output
path: dist

View File

@@ -51,7 +51,7 @@ jobs:
run: |
tar --numeric-owner --transform "s/dist/element-call-${TARBALL_VERSION}/" -cvzf element-call-${TARBALL_VERSION}.tar.gz dist
- name: Upload
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
env:
GITHUB_TOKEN: ${{ github.token }}
with:

View File

@@ -22,7 +22,7 @@ yarn
yarn build
```
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server that can be configured with custom routes (see below).
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server of your choice.
You may also wish to add a configuration file (Element Call uses the domain it's hosted on as a Homeserver URL by default,
but you can change this in the config file). This goes in `public/config.json` - you can use the sample as a starting point:
@@ -141,28 +141,6 @@ Run backend components:
yarn backend
```
### Add a new translation key
To add a new translation key you can do these steps:
1. Add the new key entry to the code where the new key is used: `t("some_new_key")`
1. Run `yarn i18n` to extract the new key and update the translation files. This will add a skeleton entry to the `public/locales/en-GB/app.json` file:
```jsonc
{
...
"some_new_key": "",
...
}
```
1. Update the skeleton entry in the `public/locales/en-GB/app.json` file with the English translation:
```jsonc
{
...
"some_new_key": "Some new key",
...
}
```
## Documentation
Usage and other technical details about the project can be found here:

View File

@@ -62,12 +62,12 @@
"i18next-http-backend": "^2.0.0",
"livekit-client": "^2.0.2",
"lodash": "^4.17.21",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#755f83039a2f2e0d3fcf7dbd116bb9ac45a3b49d",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#0fe53876ec555482bbeb086fd4dec8de7b2e21eb",
"matrix-widget-api": "^1.8.2",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
"postcss-preset-env": "^10.0.0",
"postcss-preset-env": "^9.0.0",
"posthog-js": "^1.29.0",
"react": "18",
"react-dom": "18",

View File

@@ -102,6 +102,7 @@
"microphone_off": "Microphone off",
"microphone_on": "Microphone on",
"mute_microphone_button_label": "Mute microphone",
"no_media_available": "No media available",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"rageshake_button_error_caption": "Retry sending logs",
@@ -126,6 +127,7 @@
"return_home_button": "Return to home screen",
"room_auth_view_eula_caption": "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
"room_auth_view_join_button": "Join call now",
"room_member_not_found": "Profile information for this participant is not available",
"screenshare_button_label": "Share screen",
"select_input_unset_button": "Select an option",
"settings": {

View File

@@ -15,11 +15,12 @@ limitations under the License.
*/
import classNames from "classnames";
import { FC, HTMLAttributes, ReactNode, forwardRef } from "react";
import { FC, HTMLAttributes, ReactNode, forwardRef, useMemo } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
import { Heading, Text, Tooltip } from "@vector-im/compound-web";
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc";
import styles from "./Header.module.css";
import Logo from "./icons/Logo.svg?react";
@@ -125,7 +126,7 @@ interface RoomHeaderInfoProps {
name: string;
avatarUrl: string | null;
encrypted: boolean;
participantCount: number | null;
memberships: CallMembership[] | null;
}
export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
@@ -133,11 +134,18 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
name,
avatarUrl,
encrypted,
participantCount,
memberships,
}) => {
const { t } = useTranslation();
const size = useMediaQuery("(max-width: 550px)") ? "sm" : "lg";
// Count each member only once, regardless of how many devices they use
const uniqueUsers = useMemo(
() =>
memberships ? new Set<string>(memberships.map((m) => m.sender!)) : null,
[memberships],
);
return (
<div className={styles.roomHeaderInfo} data-size={size}>
<Avatar
@@ -158,17 +166,21 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
</Heading>
<EncryptionLock encrypted={encrypted} />
</div>
{(participantCount ?? 0) > 0 && (
<div className={styles.participantsLine}>
<UserProfileIcon
width={20}
height={20}
aria-label={t("header_participants_label")}
/>
<Text as="span" size="sm" weight="medium">
{t("participant_count", { count: participantCount ?? 0 })}
</Text>
</div>
{uniqueUsers && uniqueUsers.size > 0 && (
<Tooltip
label={`${uniqueUsers.size} users with ${memberships?.length} devices:\n${[...uniqueUsers.values()].join(", ")}`}
>
<div className={styles.participantsLine}>
<UserProfileIcon
width={20}
height={20}
aria-label={t("header_participants_label")}
/>
<Text as="span" size="sm" weight="medium">
{t("participant_count", { count: uniqueUsers.size })}
</Text>
</div>
</Tooltip>
)}
</div>
);

View File

@@ -15,15 +15,12 @@ limitations under the License.
*/
import { BaseKeyProvider, createKeyMaterialFromBuffer } from "livekit-client";
import { encodeUnpaddedBase64 } from "matrix-js-sdk";
import { logger as parentLogger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/src/logger";
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
const logger = parentLogger.getChild("MatrixKeyProvider");
export class MatrixKeyProvider extends BaseKeyProvider {
private rtcSession?: MatrixRTCSession;
@@ -32,9 +29,6 @@ export class MatrixKeyProvider extends BaseKeyProvider {
}
public setRTCSession(rtcSession: MatrixRTCSession): void {
logger.debug(
`Setting new RTC session for key provider: this.rtcSession=${!!this.rtcSession}`,
);
if (this.rtcSession) {
this.rtcSession.off(
MatrixRTCSessionEvent.EncryptionKeyChanged,
@@ -66,10 +60,6 @@ export class MatrixKeyProvider extends BaseKeyProvider {
encryptionKeyIndex: number,
participantId: string,
): Promise<void> => {
logger.debug(
`Will send key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}: ${encodeUnpaddedBase64(encryptionKey)}`,
);
this.onSetEncryptionKey(
await createKeyMaterialFromBuffer(encryptionKey),
participantId,
@@ -77,7 +67,7 @@ export class MatrixKeyProvider extends BaseKeyProvider {
);
logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}: ${encodeUnpaddedBase64(encryptionKey)}`,
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
);
};
}

View File

@@ -53,7 +53,7 @@ export interface CallLayoutInputs {
export interface GridTileModel {
type: "grid";
vm: UserMediaViewModel;
vm: MediaViewModel;
}
export interface SpotlightTileModel {
@@ -95,6 +95,7 @@ export interface GridArrangement {
const tileMaxAspectRatio = 17 / 9;
const tileMinAspectRatio = 4 / 3;
const tileMobileMinAspectRatio = 2 / 3;
/**
* Determine the ideal arrangement of tiles into a grid of a particular size.
@@ -113,7 +114,7 @@ export function arrangeTiles(
const tileArea = Math.pow(Math.sqrt(area) / 8 + 125, 2);
const tilesPerPage = Math.min(tileCount, area / tileArea);
let columns = Math.min(
const columns = Math.min(
// Don't create more columns than we have items for
tilesPerPage,
// The ideal number of columns is given by a packing of equally-sized
@@ -130,21 +131,22 @@ export function arrangeTiles(
let rows = tilesPerPage / columns;
// If all the tiles could fit on one page, we want to ensure that they do by
// not leaving fractional rows hanging off the bottom
if (tilesPerPage === tileCount) {
rows = Math.ceil(rows);
// We may now be able to fit the tiles into fewer columns
columns = Math.ceil(tileCount / rows);
}
if (tilesPerPage === tileCount) rows = Math.ceil(rows);
let tileWidth = (width - (columns + 1) * gap) / columns;
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum and maximum aspect ratio on the tiles
const tileAspectRatio = tileWidth / tileHeight;
// We enforce a different min aspect ratio in 1:1s on mobile
const minAspectRatio =
tileCount === 1 && width < 600
? tileMobileMinAspectRatio
: tileMinAspectRatio;
if (tileAspectRatio > tileMaxAspectRatio)
tileWidth = tileHeight * tileMaxAspectRatio;
else if (tileAspectRatio < tileMinAspectRatio)
tileHeight = tileWidth / tileMinAspectRatio;
else if (tileAspectRatio < minAspectRatio)
tileHeight = tileWidth / minAspectRatio;
return { tileWidth, tileHeight, gap, columns };
}

View File

@@ -26,11 +26,18 @@ limitations under the License.
.local {
position: absolute;
inline-size: 180px;
block-size: 135px;
inline-size: 135px;
block-size: 160px;
inset: var(--cpd-space-4x);
}
@media (min-width: 600px) {
.local {
inline-size: 170px;
block-size: 110px;
}
}
.spotlight {
position: absolute;
inline-size: 404px;

View File

@@ -25,18 +25,11 @@ limitations under the License.
.pip {
position: absolute;
inline-size: 135px;
block-size: 160px;
inline-size: 180px;
block-size: 135px;
inset: var(--cpd-space-4x);
}
@media (min-width: 600px) {
.pip {
inline-size: 180px;
block-size: 135px;
}
}
.pip[data-block-alignment="start"] {
inset-block-end: unset;
}

View File

@@ -121,12 +121,6 @@ export const GroupCallView: FC<Props> = ({
e2eeSystem,
]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
() => new Set<string>(memberships.map((m) => m.sender!)).size,
[memberships],
);
const deviceContext = useMediaDevices();
const latestDevices = useRef<MediaDevices>();
latestDevices.current = deviceContext;
@@ -319,7 +313,7 @@ export const GroupCallView: FC<Props> = ({
onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={participantCount}
memberships={memberships}
onShareClick={onShareClick}
/>
</>
@@ -333,7 +327,6 @@ export const GroupCallView: FC<Props> = ({
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession}
participantCount={participantCount}
onLeave={onLeave}
hideHeader={hideHeader}
muteStates={muteStates}

View File

@@ -22,6 +22,31 @@ limitations under the License.
overflow-y: auto;
}
.controlsOverlay {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
overflow-inline: hidden;
/* There used to be a contain: strict here, but due to some bugs in Firefox,
this was causing the Z-ordering of modals to glitch out. It can be added back
if those issues appear to be resolved. */
}
.centerMessage {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
flex-direction: column;
}
.centerMessage p {
display: block;
margin-bottom: 0;
}
.header {
position: sticky;
flex-shrink: 0;
@@ -57,24 +82,6 @@ limitations under the License.
);
}
.footer.overlay {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
opacity: 1;
transition: opacity 0.15s;
}
.footer.overlay.hidden {
opacity: 0;
pointer-events: none;
}
.footer.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.logo {
grid-area: logo;
justify-self: start;
@@ -113,6 +120,21 @@ limitations under the License.
}
}
.footerThin {
padding-top: var(--cpd-space-3x);
padding-bottom: var(--cpd-space-5x);
}
.footerHidden {
display: none;
}
.footer.overlay {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
}
.fixedGrid {
position: absolute;
inline-size: 100%;

View File

@@ -24,9 +24,7 @@ import { ConnectionState, Room } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {
FC,
PointerEvent,
PropsWithoutRef,
TouchEvent,
forwardRef,
useCallback,
useEffect,
@@ -37,7 +35,7 @@ import {
import useMeasure from "react-use-measure";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { BehaviorSubject, of } from "rxjs";
import { BehaviorSubject } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import LogoMark from "../icons/LogoMark.svg?react";
@@ -90,8 +88,6 @@ import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
const maxTapDurationMs = 400;
export interface ActiveCallProps
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
e2eeSystem: EncryptionSystem;
@@ -128,7 +124,6 @@ export interface InCallViewProps {
rtcSession: MatrixRTCSession;
livekitRoom: Room;
muteStates: MuteStates;
participantCount: number;
onLeave: (error?: Error) => void;
hideHeader: boolean;
otelGroupCallMembership?: OTelGroupCallMembership;
@@ -142,7 +137,6 @@ export const InCallView: FC<InCallViewProps> = ({
rtcSession,
livekitRoom,
muteStates,
participantCount,
onLeave,
hideHeader,
connState,
@@ -194,7 +188,7 @@ export const InCallView: FC<InCallViewProps> = ({
const noControls = reducedControls && bounds.height <= 400;
const vm = useCallViewModel(
rtcSession.room,
rtcSession,
livekitRoom,
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState,
@@ -202,38 +196,6 @@ export const InCallView: FC<InCallViewProps> = ({
const windowMode = useObservableEagerState(vm.windowMode);
const layout = useObservableEagerState(vm.layout);
const gridMode = useObservableEagerState(vm.gridMode);
const showHeader = useObservableEagerState(vm.showHeader);
const showFooter = useObservableEagerState(vm.showFooter);
// 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
// Instead we have to watch for sufficiently fast touch events.
const touchStart = useRef<number | null>(null);
const onTouchStart = useCallback(() => (touchStart.current = Date.now()), []);
const onTouchEnd = useCallback(() => {
const start = touchStart.current;
if (start !== null && Date.now() - start <= maxTapDurationMs)
vm.tapScreen();
touchStart.current = null;
}, [vm]);
const onTouchCancel = useCallback(() => (touchStart.current = null), []);
// We also need to tell the layout toggle to prevent touch events from
// bubbling up, or else the controls will be dismissed before a change event
// can be registered on the toggle
const onLayoutToggleTouchEnd = useCallback(
(e: TouchEvent) => e.stopPropagation(),
[],
);
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (e.pointerType === "mouse") vm.hoverScreen();
},
[vm],
);
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
@@ -331,16 +293,9 @@ export const InCallView: FC<InCallViewProps> = ({
ref,
) {
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
const onToggleExpanded = useObservableEagerState(
const [onToggleExpanded] = useObservableEagerState(
vm.toggleSpotlightExpanded,
);
const showVideo = useObservableEagerState(
useMemo(
() =>
model.type === "grid" ? vm.showGridVideo(model.vm) : of(true),
[model],
),
);
const showSpeakingIndicatorsValue = useObservableEagerState(
vm.showSpeakingIndicators,
);
@@ -357,7 +312,6 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={targetHeight}
className={classNames(className, styles.tile)}
style={style}
showVideo={showVideo}
showSpeakingIndicators={showSpeakingIndicatorsValue}
/>
) : (
@@ -509,10 +463,12 @@ export const InCallView: FC<InCallViewProps> = ({
footer = (
<div
ref={footerRef}
className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat",
[styles.hidden]: !showFooter || (!showControls && hideHeader),
})}
className={classNames(
styles.footer,
!showControls &&
(hideHeader ? styles.footerHidden : styles.footerThin),
{ [styles.overlay]: windowMode === "flat" },
)}
>
{!mobile && !hideHeader && (
<div className={styles.logo}>
@@ -530,7 +486,6 @@ export const InCallView: FC<InCallViewProps> = ({
className={styles.layout}
layout={gridMode}
setLayout={setGridMode}
onTouchEnd={onLayoutToggleTouchEnd}
/>
)}
</div>
@@ -538,16 +493,9 @@ export const InCallView: FC<InCallViewProps> = ({
}
return (
<div
className={styles.inRoom}
ref={containerRef}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
onPointerMove={onPointerMove}
onPointerOut={onPointerOut}
>
{showHeader &&
<div className={styles.inRoom} ref={containerRef}>
{windowMode !== "pip" &&
windowMode !== "flat" &&
(hideHeader ? (
// Cosmetic header to fill out space while still affecting the bounds
// of the grid
@@ -563,8 +511,8 @@ export const InCallView: FC<InCallViewProps> = ({
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
memberships={rtcSession.memberships}
/>
</LeftNav>
<RightNav>
{!reducedControls && showControls && onShareClick !== null && (

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ChangeEvent, FC, TouchEvent, useCallback } from "react";
import { ChangeEvent, FC, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@vector-im/compound-web";
import {
@@ -31,15 +31,9 @@ interface Props {
layout: Layout;
setLayout: (layout: Layout) => void;
className?: string;
onTouchEnd?: (e: TouchEvent) => void;
}
export const LayoutToggle: FC<Props> = ({
layout,
setLayout,
className,
onTouchEnd,
}) => {
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
const { t } = useTranslation();
const onChange = useCallback(
@@ -56,7 +50,6 @@ export const LayoutToggle: FC<Props> = ({
value="spotlight"
checked={layout === "spotlight"}
onChange={onChange}
onTouchEnd={onTouchEnd}
/>
</Tooltip>
<SpotlightIcon aria-hidden width={24} height={24} />
@@ -67,7 +60,6 @@ export const LayoutToggle: FC<Props> = ({
value="grid"
checked={layout === "grid"}
onChange={onChange}
onTouchEnd={onTouchEnd}
/>
</Tooltip>
<GridIcon aria-hidden width={24} height={24} />

View File

@@ -20,6 +20,7 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Button, Link } from "@vector-im/compound-web";
import classNames from "classnames";
import { useHistory } from "react-router-dom";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc";
import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
@@ -46,7 +47,7 @@ interface Props {
enterLabel?: JSX.Element | string;
confineToRoom: boolean;
hideHeader: boolean;
participantCount: number | null;
memberships: CallMembership[] | null;
onShareClick: (() => void) | null;
waitingForInvite?: boolean;
}
@@ -59,7 +60,7 @@ export const LobbyView: FC<Props> = ({
enterLabel,
confineToRoom,
hideHeader,
participantCount,
memberships,
onShareClick,
waitingForInvite,
}) => {
@@ -110,7 +111,7 @@ export const LobbyView: FC<Props> = ({
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
memberships={memberships}
/>
</LeftNav>
<RightNav>

View File

@@ -142,7 +142,7 @@ export const RoomPage: FC = () => {
waitingForInvite={groupCallState.kind === "waitForInvite"}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={null}
memberships={null}
muteStates={muteStates}
onShareClick={null}
/>

View File

@@ -254,8 +254,7 @@ export const SettingsModal: FC<Props> = ({
value={duplicateTiles.toString()}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
const value = event.target.valueAsNumber;
setDuplicateTiles(Number.isNaN(value) ? 0 : value);
setDuplicateTiles(event.target.valueAsNumber);
},
[setDuplicateTiles],
)}

View File

@@ -41,21 +41,18 @@ import {
merge,
mergeAll,
of,
race,
sample,
scan,
shareReplay,
skip,
startWith,
switchAll,
switchMap,
switchScan,
take,
throttleTime,
timer,
zip,
} from "rxjs";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession, CallMembership } from "matrix-js-sdk/src/matrixrtc";
import { ViewModel } from "./ViewModel";
import { useObservable } from "./useObservable";
@@ -67,6 +64,7 @@ import { usePrevious } from "../usePrevious";
import {
LocalUserMediaViewModel,
MediaViewModel,
MembershipOnlyViewModel,
RemoteUserMediaViewModel,
ScreenShareViewModel,
UserMediaViewModel,
@@ -74,20 +72,15 @@ import {
import { accumulate, finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope";
import { duplicateTiles } from "../settings/settings";
import { isFirefox } from "../Platform";
// How long we wait after a focus switch before showing the real participant
// list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
// This is the number of participants that we think constitutes a "small" call
// on mobile. No spotlight tile should be shown below this threshold.
const smallMobileCallThreshold = 3;
export interface GridLayout {
type: "grid";
spotlight?: MediaViewModel[];
grid: UserMediaViewModel[];
grid: MediaViewModel[];
}
export interface SpotlightLandscapeLayout {
@@ -111,7 +104,7 @@ export interface SpotlightExpandedLayout {
export interface OneOnOneLayout {
type: "one-on-one";
local: LocalUserMediaViewModel;
remote: RemoteUserMediaViewModel;
remote: UserMediaViewModel;
}
export interface PipLayout {
@@ -172,15 +165,35 @@ class UserMedia {
public readonly presenter: Observable<boolean>;
public constructor(
public readonly id: string,
id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
);
public constructor(
id: string,
member: RoomMember | undefined,
callMembership: CallMembership,
);
public constructor(
public readonly id: string,
public readonly member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant | CallMembership,
callEncrypted: boolean = false,
) {
this.vm =
participant instanceof LocalParticipant
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
if (participant instanceof CallMembership) {
this.vm = new MembershipOnlyViewModel(participant, member);
} else {
this.vm =
participant instanceof LocalParticipant
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
: new RemoteUserMediaViewModel(
id,
member,
participant,
callEncrypted,
);
}
this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of
@@ -201,13 +214,17 @@ class UserMedia {
shareReplay(1),
);
this.presenter = observeParticipantEvents(
participant,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
if (participant instanceof CallMembership) {
this.presenter = of(false);
} else {
this.presenter = observeParticipantEvents(
participant,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
}
}
public destroy(): void {
@@ -235,6 +252,22 @@ class ScreenShare {
type MediaItem = UserMedia | ScreenShare;
function matrixUserIdFromParticipantId(id: string): string | undefined {
if (!id) return undefined;
const parts = id.split(":");
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
if (parts.length < 3) {
logger.warn(
`Livekit participants ID doesn't look like a userId:deviceId combination: ${id}`,
);
return undefined;
}
parts.pop();
const userId = parts.join(":");
return userId;
}
function findMatrixMember(
room: MatrixRoom,
id: string,
@@ -242,18 +275,12 @@ function findMatrixMember(
if (id === "local")
return room.getMember(room.client.getUserId()!) ?? undefined;
const parts = id.split(":");
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
if (parts.length < 3) {
logger.warn(
"Livekit participants ID doesn't look like a userId:deviceId combination",
);
const userId = matrixUserIdFromParticipantId(id);
if (!userId) {
return undefined;
}
parts.pop();
const userId = parts.join(":");
return room.getMember(userId) ?? undefined;
}
@@ -331,43 +358,88 @@ export class CallViewModel extends ViewModel {
},
);
private readonly membershipsWithoutParticipant = combineLatest([
of(this.rtcSession.memberships),
this.remoteParticipants,
of(this.livekitRoom.localParticipant),
]).pipe(
scan((prev, [memberships, remoteParticipants, localParticipant]) => {
const participantIds = new Set(
remoteParticipants.map((p) =>
matrixUserIdFromParticipantId(p.identity),
),
);
participantIds.add(
matrixUserIdFromParticipantId(localParticipant.identity),
);
return memberships.filter((m) => !participantIds.has(m.sender ?? ""));
}, [] as CallMembership[]),
);
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value,
this.membershipsWithoutParticipant,
]).pipe(
scan(
(
prevItems,
[remoteParticipants, { participant: localParticipant }, duplicateTiles],
[
remoteParticipants,
{ participant: localParticipant },
duplicateTiles,
membershipsWithoutParticipant,
],
) => {
const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) {
const userMediaId = p === localParticipant ? "local" : p.identity;
const member = findMatrixMember(this.matrixRoom, userMediaId);
if (member === undefined)
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
);
// Create as many tiles for this participant as called for by
// the duplicateTiles option
for (let i = 0; i < 1 + duplicateTiles; i++) {
const userMediaId = `${p.identity}:${i}`;
for (const p of [
localParticipant,
...remoteParticipants,
...membershipsWithoutParticipant,
]) {
if (p instanceof CallMembership) {
const member =
this.matrixRoom.getMember(p.sender!) ?? undefined;
yield [
userMediaId,
prevItems.get(userMediaId) ??
new UserMedia(userMediaId, member, p, this.encrypted),
p.sender!,
prevItems.get(p.sender!) ??
new UserMedia(p.sender!, member, p),
];
} else {
const member = findMatrixMember(
this.matrixRoom,
p === localParticipant ? "local" : p.identity,
);
if (member === undefined)
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
);
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
// Create as many tiles for this participant as called for by
// the duplicateTiles option
for (let i = 0; i < 1; i++) {
const userMediaId = `${p.identity}:${i}`;
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(screenShareId, member, p, this.encrypted),
userMediaId,
prevItems.get(userMediaId) ??
new UserMedia(userMediaId, member, p, this.encrypted),
];
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(
screenShareId,
member,
p,
this.encrypted,
),
];
}
}
}
}
@@ -424,22 +496,21 @@ export class CallViewModel extends ViewModel {
),
scan<(readonly [UserMedia, boolean])[], UserMedia, null>(
(prev, mediaItems) => {
// Only remote users that are still in the call should be sticky
const [stickyMedia, stickySpeaking] =
(!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || [];
const stickyPrev = prev === null || prev.vm.local ? null : prev;
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
return stickySpeaking
? stickyMedia!
: // Otherwise, select any remote user who is speaking
(mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
// Otherwise, stick with the person who was last speaking
stickyMedia ??
// Otherwise, spotlight an arbitrary remote user
mediaItems.find(([m]) => !m.vm.local)?.[0] ??
// Otherwise, spotlight the local user
mediaItems.find(([m]) => m.vm.local)![0]);
// If the previous speaker (not the local user) is still speaking,
// stick with them rather than switching eagerly to someone else
return (
mediaItems.find(([m, s]) => m === stickyPrev && s)?.[0] ??
// Otherwise, select any remote user who is speaking
mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
// Otherwise, stick with the person who was last speaking
stickyPrev ??
// Otherwise, spotlight an arbitrary remote user
mediaItems.find(([m]) => !m.vm.local)?.[0] ??
// Otherwise, spotlight the local user
mediaItems.find(([m]) => m.vm.local)![0]
);
},
null,
),
@@ -493,17 +564,11 @@ export class CallViewModel extends ViewModel {
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
: ([
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
this.spotlightSpeaker.pipe(
switchMap((speaker) =>
speaker.local
? of(null)
: this.localUserMedia.pipe(
switchMap((vm) =>
vm.alwaysShow.pipe(
map((alwaysShow) => (alwaysShow ? vm : null)),
),
),
),
this.localUserMedia.pipe(
switchMap((vm) =>
vm.alwaysShow.pipe(
map((alwaysShow) => (alwaysShow ? vm : null)),
),
),
),
] as const),
@@ -531,11 +596,8 @@ export class CallViewModel extends ViewModel {
const height = window.innerHeight;
const width = window.innerWidth;
if (height <= 400 && width <= 340) return "pip";
// Our layouts for flat windows are better at adapting to a small width
// than our layouts for narrow windows are at adapting to a small height,
// so we give "flat" precedence here
if (height <= 600) return "flat";
if (width <= 600) return "narrow";
if (width <= 660) return "narrow";
if (height <= 660) return "flat";
return "normal";
}),
distinctUntilChanged(),
@@ -580,98 +642,75 @@ export class CallViewModel extends ViewModel {
this.gridModeUserSelection.next(value);
}
private readonly oneOnOne: Observable<boolean> = combineLatest(
[this.grid, this.screenShares],
(grid, screenShares) =>
grid.length == 2 &&
// There might not be a remote tile if only the local user is in the call
// and they're using the duplicate tiles option
grid.some((vm) => !vm.local) &&
screenShares.length === 0,
);
private readonly gridLayout: Observable<Layout> = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) => ({
type: "grid",
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? spotlight
: undefined,
grid,
}),
);
private readonly spotlightLandscapeLayout: Observable<Layout> = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) => ({ type: "spotlight-landscape", spotlight, grid }),
);
private readonly spotlightPortraitLayout: Observable<Layout> = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) => ({ type: "spotlight-portrait", spotlight, grid }),
);
private readonly spotlightExpandedLayout: Observable<Layout> = combineLatest(
[this.spotlight, this.pip],
(spotlight, pip) => ({
type: "spotlight-expanded",
spotlight,
pip: pip ?? undefined,
}),
);
private readonly oneOnOneLayout: Observable<Layout> = this.grid.pipe(
map((grid) => ({
type: "one-on-one",
local: grid.find((vm) => vm.local) as LocalUserMediaViewModel,
remote: grid.find((vm) => !vm.local) as RemoteUserMediaViewModel,
})),
);
private readonly pipLayout: Observable<Layout> = this.spotlight.pipe(
map((spotlight): Layout => ({ type: "pip", spotlight })),
);
public readonly layout: Observable<Layout> = this.windowMode.pipe(
switchMap((windowMode) => {
const spotlightLandscapeLayout = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): SpotlightLandscapeLayout => ({
type: "spotlight-landscape",
spotlight,
grid,
}),
);
const spotlightExpandedLayout = combineLatest(
[this.spotlight, this.pip],
(spotlight, pip): SpotlightExpandedLayout => ({
type: "spotlight-expanded",
spotlight,
pip: pip ?? undefined,
}),
);
switch (windowMode) {
case "normal":
return this.gridMode.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
return this.oneOnOne.pipe(
switchMap((oneOnOne) =>
oneOnOne ? this.oneOnOneLayout : this.gridLayout,
),
return combineLatest(
[this.grid, this.spotlight, this.screenShares],
(grid, spotlight, screenShares): Layout =>
grid.length == 2 &&
// There might not be a remote tile if only the local user
// is in the call and they're using the duplicate tiles
// option
grid.some((vm) => !vm.local) &&
screenShares.length === 0
? {
type: "one-on-one",
local: grid.find(
(vm) => vm.local,
) as LocalUserMediaViewModel,
remote: grid.find(
(vm) => !vm.local,
) as RemoteUserMediaViewModel,
}
: {
type: "grid",
spotlight:
screenShares.length > 0 ? spotlight : undefined,
grid,
},
);
case "spotlight":
return this.spotlightExpanded.pipe(
switchMap((expanded) =>
expanded
? this.spotlightExpandedLayout
: this.spotlightLandscapeLayout,
? spotlightExpandedLayout
: spotlightLandscapeLayout,
),
);
}
}),
);
case "narrow":
return this.oneOnOne.pipe(
switchMap((oneOnOne) =>
oneOnOne
? // The expanded spotlight layout makes for a better one-on-one
// experience in narrow windows
this.spotlightExpandedLayout
: combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? this.spotlightPortraitLayout
: this.gridLayout,
).pipe(switchAll()),
),
return combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): SpotlightPortraitLayout => ({
type: "spotlight-portrait",
spotlight,
grid,
}),
);
case "flat":
return this.gridMode.pipe(
@@ -680,14 +719,16 @@ export class CallViewModel extends ViewModel {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return this.spotlightLandscapeLayout;
return spotlightLandscapeLayout;
case "spotlight":
return this.spotlightExpandedLayout;
return spotlightExpandedLayout;
}
}),
);
case "pip":
return this.pipLayout;
return this.spotlight.pipe(
map((spotlight): PipLayout => ({ type: "pip", spotlight })),
);
}
}),
shareReplay(1),
@@ -699,129 +740,38 @@ export class CallViewModel extends ViewModel {
shareReplay(1),
);
/**
* Determines whether video should be shown for a certain piece of media
* appearing in the grid.
*/
public showGridVideo(vm: MediaViewModel): Observable<boolean> {
return this.layout.pipe(
map(
(l) =>
!(
(l.type === "spotlight-landscape" ||
l.type === "spotlight-portrait") &&
// This media is already visible in the spotlight; avoid duplication
l.spotlight.some((spotlightVm) => spotlightVm === vm)
),
),
distinctUntilChanged(),
);
}
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
map((l) => l.type !== "one-on-one" && !l.type.startsWith("spotlight-")),
map((l) => l.type !== "one-on-one" && l.type !== "spotlight-expanded"),
distinctUntilChanged(),
shareReplay(1),
);
public readonly toggleSpotlightExpanded: Observable<(() => void) | null> =
this.windowMode.pipe(
switchMap((mode) =>
mode === "normal"
? this.layout.pipe(
map(
(l) =>
l.type === "spotlight-landscape" ||
l.type === "spotlight-expanded",
),
)
: of(false),
),
distinctUntilChanged(),
map((enabled) =>
enabled ? (): void => this.spotlightExpandedToggle.next() : null,
),
shareReplay(1),
);
private readonly screenTap = new Subject<void>();
private readonly screenHover = new Subject<void>();
private readonly screenUnhover = new Subject<void>();
/**
* Callback for when the user taps the call view.
*/
public tapScreen(): void {
this.screenTap.next();
}
/**
* Callback for when the user hovers over the call view.
*/
public hoverScreen(): void {
this.screenHover.next();
}
/**
* Callback for when the user stops hovering over the call view.
*/
public unhoverScreen(): void {
this.screenUnhover.next();
}
public readonly showHeader: Observable<boolean> = this.windowMode.pipe(
map((mode) => mode !== "pip" && mode !== "flat"),
// To work around https://github.com/crimx/observable-hooks/issues/131 we must
// wrap the emitted function here in a non-function wrapper type
public readonly toggleSpotlightExpanded: Observable<
readonly [(() => void) | null]
> = this.layout.pipe(
map(
(l) =>
l.type === "spotlight-landscape" || l.type === "spotlight-expanded",
),
distinctUntilChanged(),
map(
(enabled) =>
[
enabled ? (): void => this.spotlightExpandedToggle.next() : null,
] as const,
),
shareReplay(1),
);
public readonly showFooter = this.windowMode.pipe(
switchMap((mode) => {
switch (mode) {
case "pip":
return of(false);
case "normal":
case "narrow":
return of(true);
case "flat":
// Sadly Firefox has some layering glitches that prevent the footer
// from appearing properly. They happen less often if we never hide
// the footer.
if (isFirefox()) return of(true);
// Show/hide the footer in response to interactions
return merge(
this.screenTap.pipe(map(() => "tap" as const)),
this.screenHover.pipe(map(() => "hover" as const)),
).pipe(
switchScan(
(state, interaction) =>
interaction === "tap"
? state
? // Toggle visibility on tap
of(false)
: // Hide after a timeout
timer(6000).pipe(
map(() => false),
startWith(true),
)
: // Show on hover and hide after a timeout
race(timer(3000), this.screenUnhover.pipe(take(1))).pipe(
map(() => false),
startWith(true),
),
false,
),
startWith(false),
);
}
}),
distinctUntilChanged(),
shareReplay(1),
);
private get matrixRoom(): MatrixRoom {
return this.rtcSession.room;
}
public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom,
private readonly rtcSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom,
private readonly encrypted: boolean,
private readonly connectionState: Observable<ECConnectionState>,
@@ -831,25 +781,25 @@ export class CallViewModel extends ViewModel {
}
export function useCallViewModel(
matrixRoom: MatrixRoom,
rtcSession: MatrixRTCSession,
livekitRoom: LivekitRoom,
encrypted: boolean,
connectionState: ECConnectionState,
): CallViewModel {
const prevMatrixRoom = usePrevious(matrixRoom);
const prevRTCSession = usePrevious(rtcSession);
const prevLivekitRoom = usePrevious(livekitRoom);
const prevEncrypted = usePrevious(encrypted);
const connectionStateObservable = useObservable(connectionState);
const vm = useRef<CallViewModel>();
if (
matrixRoom !== prevMatrixRoom ||
rtcSession !== prevRTCSession ||
livekitRoom !== prevLivekitRoom ||
encrypted !== prevEncrypted
) {
vm.current?.destroy();
vm.current = new CallViewModel(
matrixRoom,
rtcSession,
livekitRoom,
encrypted,
connectionStateObservable,

View File

@@ -46,6 +46,7 @@ import {
switchMap,
} from "rxjs";
import { useEffect } from "react";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc";
import { ViewModel } from "./ViewModel";
import { useReactiveState } from "../useReactiveState";
@@ -92,11 +93,14 @@ abstract class BaseMediaViewModel extends ViewModel {
/**
* Whether the media belongs to the local user.
*/
public readonly local = this.participant.isLocal;
public get local(): boolean {
return this.participant?.isLocal ?? false;
}
/**
* The LiveKit video track for this media.
*/
public readonly video: Observable<TrackReferenceOrPlaceholder>;
public readonly video: Observable<TrackReferenceOrPlaceholder | undefined>;
/**
* Whether there should be a warning that this media is unencrypted.
*/
@@ -113,21 +117,29 @@ abstract class BaseMediaViewModel extends ViewModel {
// TODO: Fully separate the data layer from the UI layer by keeping the
// member object internal
public readonly member: RoomMember | undefined,
protected readonly participant: LocalParticipant | RemoteParticipant,
protected readonly participant:
| LocalParticipant
| RemoteParticipant
| undefined,
callEncrypted: boolean,
audioSource: AudioSource,
videoSource: VideoSource,
) {
super();
const audio = observeTrackReference(participant, audioSource);
this.video = observeTrackReference(participant, videoSource);
this.unencryptedWarning = combineLatest(
[audio, this.video],
(a, v) =>
callEncrypted &&
(a.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false),
).pipe(distinctUntilChanged(), shareReplay(1));
if (participant) {
const audio = observeTrackReference(participant, audioSource);
this.video = observeTrackReference(participant, videoSource);
this.unencryptedWarning = combineLatest(
[audio, this.video],
(a, v) =>
callEncrypted &&
(a.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false),
).pipe(distinctUntilChanged(), shareReplay(1));
} else {
this.video = of(undefined);
this.unencryptedWarning = of(false);
}
}
}
@@ -137,7 +149,8 @@ abstract class BaseMediaViewModel extends ViewModel {
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
export type UserMediaViewModel =
| LocalUserMediaViewModel
| RemoteUserMediaViewModel;
| RemoteUserMediaViewModel
| MembershipOnlyViewModel;
/**
* Some participant's user media.
@@ -146,13 +159,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/**
* Whether the participant is speaking.
*/
public readonly speaking = observeParticipantEvents(
this.participant,
ParticipantEvent.IsSpeakingChanged,
).pipe(
map((p) => p.isSpeaking),
shareReplay(1),
);
public readonly speaking: Observable<boolean>;
/**
* Whether this participant is sending audio (i.e. is unmuted on their side).
@@ -172,7 +179,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
public constructor(
id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
participant: LocalParticipant | RemoteParticipant | undefined,
callEncrypted: boolean,
) {
super(
@@ -184,13 +191,26 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
Track.Source.Camera,
);
const media = observeParticipantMedia(participant).pipe(shareReplay(1));
this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false),
);
this.videoEnabled = media.pipe(
map((m) => m.cameraTrack?.isMuted === false),
);
if (participant) {
this.speaking = observeParticipantEvents(
participant,
ParticipantEvent.IsSpeakingChanged,
).pipe(
map((p) => p.isSpeaking),
shareReplay(1),
);
const media = observeParticipantMedia(participant).pipe(shareReplay(1));
this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false),
);
this.videoEnabled = media.pipe(
map((m) => m.cameraTrack?.isMuted === false),
);
} else {
this.speaking = of(false);
this.audioEnabled = of(false);
this.videoEnabled = of(false);
}
}
public toggleFitContain(): void {
@@ -207,7 +227,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
*/
public readonly mirror = this.video.pipe(
switchMap((v) => {
const track = v.publication?.track;
const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe(
@@ -300,3 +320,20 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
);
}
}
/**
* Placeholder for a call membership that does not have a LiveKit participant associated with it.
*/
export class MembershipOnlyViewModel extends BaseUserMediaViewModel {
public constructor(
public readonly callMembership: CallMembership,
member: RoomMember | undefined,
) {
super(
callMembership.sender ?? callMembership.membershipID,
member,
undefined,
false,
);
}
}

View File

@@ -49,6 +49,8 @@ import {
useDisplayName,
LocalUserMediaViewModel,
RemoteUserMediaViewModel,
MembershipOnlyViewModel,
MediaViewModel,
} from "../state/MediaViewModel";
import { Slider } from "../Slider";
import { MediaView } from "./MediaView";
@@ -60,7 +62,6 @@ interface TileProps {
targetWidth: number;
targetHeight: number;
displayName: string;
showVideo: boolean;
showSpeakingIndicators: boolean;
}
@@ -75,7 +76,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
(
{
vm,
showVideo,
showSpeakingIndicators,
menuStart,
menuEnd,
@@ -122,7 +122,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
videoEnabled={videoEnabled && showVideo}
videoEnabled={videoEnabled}
videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeakingIndicators && speaking,
@@ -274,14 +274,35 @@ const RemoteUserMediaTile = forwardRef<
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface MembershipOnlyTileProps extends TileProps {
vm: MembershipOnlyViewModel;
}
const MembershipOnlyTile = forwardRef<HTMLDivElement, MembershipOnlyTileProps>(
({ vm, ...props }, ref) => {
return (
<MediaView
mirror={false}
ref={ref}
member={vm.member}
unencryptedWarning={false}
videoEnabled={false}
videoFit="contain"
{...props}
/>
);
},
);
MembershipOnlyTile.displayName = "MembershipOnlyTile";
interface GridTileProps {
vm: UserMediaViewModel;
vm: MediaViewModel;
onOpenProfile: () => void;
targetWidth: number;
targetHeight: number;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showVideo: boolean;
showSpeakingIndicators: boolean;
}
@@ -299,15 +320,10 @@ export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
{...props}
/>
);
} else {
return (
<RemoteUserMediaTile
ref={ref}
vm={vm}
displayName={displayName}
{...props}
/>
);
} else if (vm instanceof RemoteUserMediaViewModel) {
return <RemoteUserMediaTile ref={ref} vm={vm} displayName={displayName} {...props} />;
} else if (vm instanceof MembershipOnlyViewModel) {
return <MembershipOnlyTile ref={ref} vm={vm} displayName={displayName} {...props} />;
}
},
);

View File

@@ -94,7 +94,7 @@ unconditionally select the container so we can use cqmin units */
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto;
grid-template-areas: ". ." "nameTag button";
grid-template-areas: "status status" "nameTag button";
gap: var(--cpd-space-1x);
place-items: start;
}
@@ -177,3 +177,27 @@ unconditionally select the container so we can use cqmin units */
.fg > button:first-of-type {
grid-area: button;
}
.status {
grid-area: status;
padding: var(--cpd-space-1x);
padding-block: var(--cpd-space-1x);
color: var(--cpd-color-text-primary);
background-color: var(--cpd-color-bg-canvas-default);
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);
user-select: none;
overflow: hidden;
box-shadow: var(--small-drop-shadow);
box-sizing: border-box;
max-inline-size: 100%;
}
.status > span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-inline: var(--cpd-space-2x);
flex-shrink: 1;
}

View File

@@ -32,7 +32,7 @@ interface Props extends ComponentProps<typeof animated.div> {
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder;
video?: TrackReferenceOrPlaceholder;
videoFit: "cover" | "contain";
mirror: boolean;
member: RoomMember | undefined;
@@ -85,21 +85,33 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
/>
{video.publication !== undefined && (
{video?.publication ? (
<VideoTrack
trackRef={video}
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
/>
)}
) : null}
</div>
<div className={styles.fg}>
{!video ? (
<div className={styles.status}>
<span>{t("no_media_available")}</span>
</div>
) : null}
{!member ? (
<div className={styles.status}>
<span>{t("room_member_not_found")}</span>
</div>
) : null}
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}>
{displayName}
</Text>
<Tooltip label={member?.userId ?? t("room_member_not_found")}>
<Text as="span" size="sm" weight="medium" className={styles.name}>
{displayName}
</Text>
</Tooltip>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}

View File

@@ -57,7 +57,7 @@ interface SpotlightItemBaseProps {
"data-id": string;
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder;
video: TrackReferenceOrPlaceholder | undefined;
member: RoomMember | undefined;
unencryptedWarning: boolean;
displayName: string;

2294
yarn.lock

File diff suppressed because it is too large Load Diff