mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Merge remote-tracking branch 'origin/livekit' into hs/emoji-reactions
This commit is contained in:
2
.github/workflows/docker.yaml
vendored
2
.github/workflows/docker.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check it out
|
- name: Check it out
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
|
||||||
- name: 📥 Download artifact
|
- name: 📥 Download artifact
|
||||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||||
|
|||||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out test private repo
|
- name: Check out test private repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
with:
|
with:
|
||||||
repository: element-hq/static-call-participant
|
repository: element-hq/static-call-participant
|
||||||
ref: refs/heads/main
|
ref: refs/heads/main
|
||||||
|
|||||||
4
.github/workflows/element-call.yaml
vendored
4
.github/workflows/element-call.yaml
vendored
@@ -21,9 +21,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
|
|||||||
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
@@ -7,9 +7,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
|
|||||||
4
.github/workflows/test.yaml
vendored
4
.github/workflows/test.yaml
vendored
@@ -9,9 +9,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
|
|||||||
4
.github/workflows/translations-download.yaml
vendored
4
.github/workflows/translations-download.yaml
vendored
@@ -13,9 +13,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
|
||||||
- uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
|
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
|
|||||||
2
.github/workflows/translations-upload.yaml
vendored
2
.github/workflows/translations-upload.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ confineToRoom: boolean; (default: false)
|
|||||||
Whether upon entering a room, the user should be prompted to launch the
|
Whether upon entering a room, the user should be prompted to launch the
|
||||||
native mobile app. (Affects only Android and iOS.)
|
native mobile app. (Affects only Android and iOS.)
|
||||||
|
|
||||||
|
The app prompt must also be enabled in the config for this to take effect.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
appPrompt: boolean; (default: true)
|
appPrompt: boolean; (default: true)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
"stop_screenshare_button_label": "Sharing screen",
|
"stop_screenshare_button_label": "Sharing screen",
|
||||||
"stop_video_button_label": "Stop video",
|
"stop_video_button_label": "Stop video",
|
||||||
"submitting": "Submitting…",
|
"submitting": "Submitting…",
|
||||||
|
"switch_camera": "Switch camera",
|
||||||
"unauthenticated_view_body": "Not registered yet? <2>Create an account</2>",
|
"unauthenticated_view_body": "Not registered yet? <2>Create an account</2>",
|
||||||
"unauthenticated_view_eula_caption": "By clicking \"Go\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
|
"unauthenticated_view_eula_caption": "By clicking \"Go\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
|
||||||
"unauthenticated_view_login_button": "Login to your account",
|
"unauthenticated_view_login_button": "Login to your account",
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface UrlParams {
|
|||||||
/**
|
/**
|
||||||
* Whether upon entering a room, the user should be prompted to launch the
|
* Whether upon entering a room, the user should be prompted to launch the
|
||||||
* native mobile app. (Affects only Android and iOS.)
|
* native mobile app. (Affects only Android and iOS.)
|
||||||
|
*
|
||||||
|
* The app prompt must also be enabled in the config for this to take effect.
|
||||||
*/
|
*/
|
||||||
appPrompt: boolean;
|
appPrompt: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
EndCallIcon,
|
EndCallIcon,
|
||||||
ShareScreenSolidIcon,
|
ShareScreenSolidIcon,
|
||||||
SettingsSolidIcon,
|
SettingsSolidIcon,
|
||||||
|
SwitchCameraSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import styles from "./Button.module.css";
|
import styles from "./Button.module.css";
|
||||||
@@ -66,6 +67,23 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SwitchCameraButton: FC<ComponentPropsWithoutRef<"button">> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={t("switch_camera")}>
|
||||||
|
<CpdButton
|
||||||
|
iconOnly
|
||||||
|
Icon={SwitchCameraSolidIcon}
|
||||||
|
kind="secondary"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
133
src/button/RaisedHandToggleButton.tsx
Normal file
133
src/button/RaisedHandToggleButton.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
|
||||||
|
import {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
FC,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
|
|
||||||
|
import { useReactions } from "../useReactions";
|
||||||
|
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||||
|
|
||||||
|
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
|
raised: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InnerButton: FC<InnerButtonProps> = ({ raised, ...props }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={t("common.raise_hand")}>
|
||||||
|
<CpdButton
|
||||||
|
kind={raised ? "primary" : "secondary"}
|
||||||
|
{...props}
|
||||||
|
style={{ paddingLeft: 8, paddingRight: 8 }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
role="img"
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
width: "30px",
|
||||||
|
height: "0px",
|
||||||
|
display: "inline-block",
|
||||||
|
fontSize: "22px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✋
|
||||||
|
</p>
|
||||||
|
</CpdButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RaisedHandToggleButtonProps {
|
||||||
|
rtcSession: MatrixRTCSession;
|
||||||
|
client: MatrixClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RaiseHandToggleButton({
|
||||||
|
client,
|
||||||
|
rtcSession,
|
||||||
|
}: RaisedHandToggleButtonProps): ReactNode {
|
||||||
|
const { raisedHands, myReactionId } = useReactions();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const userId = client.getUserId()!;
|
||||||
|
const isHandRaised = !!raisedHands[userId];
|
||||||
|
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||||
|
|
||||||
|
const toggleRaisedHand = useCallback(() => {
|
||||||
|
const raiseHand = async (): Promise<void> => {
|
||||||
|
if (isHandRaised) {
|
||||||
|
if (!myReactionId) {
|
||||||
|
logger.warn(`Hand raised but no reaction event to redact!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
await client.redactEvent(rtcSession.room.roomId, myReactionId);
|
||||||
|
logger.debug("Redacted raise hand event");
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error("Failed to redact reaction event", myReactionId, ex);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const myMembership = memberships.find((m) => m.sender === userId);
|
||||||
|
if (!myMembership?.eventId) {
|
||||||
|
logger.error("Cannot find own membership event");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parentEventId = myMembership.eventId;
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
const reaction = await client.sendEvent(
|
||||||
|
rtcSession.room.roomId,
|
||||||
|
EventType.Reaction,
|
||||||
|
{
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: RelationType.Annotation,
|
||||||
|
event_id: parentEventId,
|
||||||
|
key: "🖐️",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
logger.debug("Sent raise hand event", reaction.event_id);
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error("Failed to send reaction event", ex);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void raiseHand();
|
||||||
|
}, [
|
||||||
|
client,
|
||||||
|
isHandRaised,
|
||||||
|
memberships,
|
||||||
|
myReactionId,
|
||||||
|
rtcSession.room.roomId,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InnerButton
|
||||||
|
disabled={busy}
|
||||||
|
onClick={toggleRaisedHand}
|
||||||
|
raised={isHandRaised}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,6 +88,14 @@ export interface ConfigOptions {
|
|||||||
*/
|
*/
|
||||||
enable_video?: boolean;
|
enable_video?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether upon entering a room, the user should be prompted to launch the
|
||||||
|
* native mobile app. (Affects only Android and iOS.)
|
||||||
|
*
|
||||||
|
* Note that this can additionally be disabled by the app's URL parameters.
|
||||||
|
*/
|
||||||
|
app_prompt?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overrides members from ConfigOptions that are always provided by the
|
// Overrides members from ConfigOptions that are always provided by the
|
||||||
@@ -103,6 +111,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
|||||||
enable_audio: boolean;
|
enable_audio: boolean;
|
||||||
enable_video: boolean;
|
enable_video: boolean;
|
||||||
};
|
};
|
||||||
|
app_prompt: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||||
@@ -117,4 +126,5 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
|||||||
enable_audio: true,
|
enable_audio: true,
|
||||||
enable_video: true,
|
enable_video: true,
|
||||||
},
|
},
|
||||||
|
app_prompt: true,
|
||||||
};
|
};
|
||||||
|
|||||||
52
src/reactions/RaisedHandIndicator.module.css
Normal file
52
src/reactions/RaisedHandIndicator.module.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
.raisedHandWidget {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--cpd-color-bg-subtle-primary);
|
||||||
|
border-radius: var(--cpd-radius-pill-effect);
|
||||||
|
color: var(--cpd-color-icon-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raisedHandWidget > p {
|
||||||
|
padding: none;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
width: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raisedHandWidgetLarge > p {
|
||||||
|
padding: var(--cpd-space-2x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raisedHandLarge {
|
||||||
|
margin: var(--cpd-space-2x);
|
||||||
|
padding: var(--cpd-space-2x);
|
||||||
|
padding-block: var(--cpd-space-2x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raisedHand {
|
||||||
|
margin: var(--cpd-space-1x);
|
||||||
|
color: var(--cpd-color-icon-secondary);
|
||||||
|
background-color: var(--cpd-color-icon-secondary);
|
||||||
|
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%;
|
||||||
|
max-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raisedHand > span {
|
||||||
|
width: var(--cpd-space-6x);
|
||||||
|
height: var(--cpd-space-6x);
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raisedHandLarge > span {
|
||||||
|
width: var(--cpd-space-8x);
|
||||||
|
height: var(--cpd-space-8x);
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import "@formatjs/intl-durationformat/polyfill";
|
import "@formatjs/intl-durationformat/polyfill";
|
||||||
import { DurationFormat } from "@formatjs/intl-durationformat";
|
import { DurationFormat } from "@formatjs/intl-durationformat";
|
||||||
|
|
||||||
import { ReactionIndicator } from "./ReactionIndicator";
|
import { ReactionIndicator } from "./ReactionIndicator";
|
||||||
|
|
||||||
const durationFormatter = new DurationFormat(undefined, {
|
const durationFormatter = new DurationFormat(undefined, {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
ShareScreenButton,
|
ShareScreenButton,
|
||||||
SettingsButton,
|
SettingsButton,
|
||||||
ReactionToggleButton,
|
ReactionToggleButton,
|
||||||
|
SwitchCameraButton,
|
||||||
} from "../button";
|
} from "../button";
|
||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||||
import { useUrlParams } from "../UrlParams";
|
import { useUrlParams } from "../UrlParams";
|
||||||
@@ -84,6 +85,7 @@ import { ReactionsProvider, useReactions } from "../useReactions";
|
|||||||
import handSoundOgg from "../sound/raise_hand.ogg?url";
|
import handSoundOgg from "../sound/raise_hand.ogg?url";
|
||||||
import handSoundMp3 from "../sound/raise_hand.mp3?url";
|
import handSoundMp3 from "../sound/raise_hand.mp3?url";
|
||||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||||
|
import { useSwitchCamera } from "./useSwitchCamera";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
props.rtcSession.room,
|
props.rtcSession.room,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
props.e2eeSystem.kind !== E2eeType.NONE,
|
props.e2eeSystem,
|
||||||
connStateObservable,
|
connStateObservable,
|
||||||
);
|
);
|
||||||
setVm(vm);
|
setVm(vm);
|
||||||
@@ -128,7 +130,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
}, [
|
}, [
|
||||||
props.rtcSession.room,
|
props.rtcSession.room,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
props.e2eeSystem.kind,
|
props.e2eeSystem,
|
||||||
connStateObservable,
|
connStateObservable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -242,6 +244,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const gridMode = useObservableEagerState(vm.gridMode);
|
const gridMode = useObservableEagerState(vm.gridMode);
|
||||||
const showHeader = useObservableEagerState(vm.showHeader);
|
const showHeader = useObservableEagerState(vm.showHeader);
|
||||||
const showFooter = useObservableEagerState(vm.showFooter);
|
const showFooter = useObservableEagerState(vm.showFooter);
|
||||||
|
const switchCamera = useSwitchCamera(vm.localVideo);
|
||||||
|
|
||||||
// Ideally we could detect taps by listening for click events and checking
|
// 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
|
// that the pointerType of the event is "touch", but this isn't yet supported
|
||||||
@@ -285,10 +288,17 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
[setSettingsModalOpen],
|
[setSettingsModalOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openProfile = useCallback(() => {
|
const openProfile = useMemo(
|
||||||
setSettingsTab("profile");
|
() =>
|
||||||
setSettingsModalOpen(true);
|
// Profile settings are unavailable in widget mode
|
||||||
}, [setSettingsTab, setSettingsModalOpen]);
|
widget === null
|
||||||
|
? (): void => {
|
||||||
|
setSettingsTab("profile");
|
||||||
|
setSettingsModalOpen(true);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[setSettingsTab, setSettingsModalOpen],
|
||||||
|
);
|
||||||
|
|
||||||
const [headerRef, headerBounds] = useMeasure();
|
const [headerRef, headerBounds] = useMeasure();
|
||||||
const [footerRef, footerBounds] = useMeasure();
|
const [footerRef, footerBounds] = useMeasure();
|
||||||
@@ -526,14 +536,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
|
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<MicButton
|
<MicButton
|
||||||
key="1"
|
key="audio"
|
||||||
muted={!muteStates.audio.enabled}
|
muted={!muteStates.audio.enabled}
|
||||||
onClick={toggleMicrophone}
|
onClick={toggleMicrophone}
|
||||||
disabled={muteStates.audio.setEnabled === null}
|
disabled={muteStates.audio.setEnabled === null}
|
||||||
data-testid="incall_mute"
|
data-testid="incall_mute"
|
||||||
/>,
|
/>,
|
||||||
<VideoButton
|
<VideoButton
|
||||||
key="2"
|
key="video"
|
||||||
muted={!muteStates.video.enabled}
|
muted={!muteStates.video.enabled}
|
||||||
onClick={toggleCamera}
|
onClick={toggleCamera}
|
||||||
disabled={muteStates.video.setEnabled === null}
|
disabled={muteStates.video.setEnabled === null}
|
||||||
@@ -541,10 +551,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
if (!reducedControls) {
|
if (!reducedControls) {
|
||||||
|
if (switchCamera !== null)
|
||||||
|
buttons.push(
|
||||||
|
<SwitchCameraButton key="switch_camera" onClick={switchCamera} />,
|
||||||
|
);
|
||||||
if (canScreenshare && !hideScreensharing) {
|
if (canScreenshare && !hideScreensharing) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<ShareScreenButton
|
<ShareScreenButton
|
||||||
key="3"
|
key="share_screen"
|
||||||
enabled={isScreenShareEnabled}
|
enabled={isScreenShareEnabled}
|
||||||
onClick={toggleScreensharing}
|
onClick={toggleScreensharing}
|
||||||
data-testid="incall_screenshare"
|
data-testid="incall_screenshare"
|
||||||
@@ -560,12 +574,12 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
buttons.push(<SettingsButton key="5" onClick={openSettings} />);
|
buttons.push(<SettingsButton key="settings" onClick={openSettings} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<EndCallButton
|
<EndCallButton
|
||||||
key="6"
|
key="end_call"
|
||||||
onClick={function (): void {
|
onClick={function (): void {
|
||||||
onLeave();
|
onLeave();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FC, useCallback, useState } from "react";
|
import { FC, useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import { Button } from "@vector-im/compound-web";
|
import { Button } from "@vector-im/compound-web";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { usePreviewTracks } from "@livekit/components-react";
|
||||||
|
import { LocalVideoTrack, Track } from "livekit-client";
|
||||||
|
import { useObservable } from "observable-hooks";
|
||||||
|
import { map } from "rxjs";
|
||||||
|
|
||||||
import inCallStyles from "./InCallView.module.css";
|
import inCallStyles from "./InCallView.module.css";
|
||||||
import styles from "./LobbyView.module.css";
|
import styles from "./LobbyView.module.css";
|
||||||
@@ -23,12 +28,16 @@ import {
|
|||||||
EndCallButton,
|
EndCallButton,
|
||||||
MicButton,
|
MicButton,
|
||||||
SettingsButton,
|
SettingsButton,
|
||||||
|
SwitchCameraButton,
|
||||||
VideoButton,
|
VideoButton,
|
||||||
} from "../button/Button";
|
} from "../button/Button";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
import { useMediaQuery } from "../useMediaQuery";
|
import { useMediaQuery } from "../useMediaQuery";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { Link } from "../button/Link";
|
import { Link } from "../button/Link";
|
||||||
|
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
import { useSwitchCamera } from "./useSwitchCamera";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -89,6 +98,61 @@ export const LobbyView: FC<Props> = ({
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const devices = useMediaDevices();
|
||||||
|
|
||||||
|
// Capture the audio options as they were when we first mounted, because
|
||||||
|
// we're not doing anything with the audio anyway so we don't need to
|
||||||
|
// re-open the devices when they change (see below).
|
||||||
|
const initialAudioOptions = useInitial(
|
||||||
|
() =>
|
||||||
|
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const localTrackOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
// The only reason we request audio here is to get the audio permission
|
||||||
|
// request over with at the same time. But changing the audio settings
|
||||||
|
// shouldn't cause this hook to recreate the track, which is why we
|
||||||
|
// reference the initial values here.
|
||||||
|
// We also pass in a clone because livekit mutates the object passed in,
|
||||||
|
// which would cause the devices to be re-opened on the next render.
|
||||||
|
audio: Object.assign({}, initialAudioOptions),
|
||||||
|
video: muteStates.video.enabled && {
|
||||||
|
deviceId: devices.videoInput.selectedId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
initialAudioOptions,
|
||||||
|
devices.videoInput.selectedId,
|
||||||
|
muteStates.video.enabled,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onError = useCallback(
|
||||||
|
(error: Error) => {
|
||||||
|
logger.error("Error while creating preview Tracks:", error);
|
||||||
|
muteStates.audio.setEnabled?.(false);
|
||||||
|
muteStates.video.setEnabled?.(false);
|
||||||
|
},
|
||||||
|
[muteStates.audio, muteStates.video],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tracks = usePreviewTracks(localTrackOptions, onError);
|
||||||
|
|
||||||
|
const videoTrack = useMemo(
|
||||||
|
() =>
|
||||||
|
(tracks?.find((t) => t.kind === Track.Kind.Video) ??
|
||||||
|
null) as LocalVideoTrack | null,
|
||||||
|
[tracks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const switchCamera = useSwitchCamera(
|
||||||
|
useObservable(
|
||||||
|
(inputs) => inputs.pipe(map(([video]) => video)),
|
||||||
|
[videoTrack],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||||
// animations and don't have to feel bad about reusing its CSS
|
// animations and don't have to feel bad about reusing its CSS
|
||||||
return (
|
return (
|
||||||
@@ -111,7 +175,11 @@ export const LobbyView: FC<Props> = ({
|
|||||||
</Header>
|
</Header>
|
||||||
)}
|
)}
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}>
|
<VideoPreview
|
||||||
|
matrixInfo={matrixInfo}
|
||||||
|
muteStates={muteStates}
|
||||||
|
videoTrack={videoTrack}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
className={classNames(styles.join, {
|
className={classNames(styles.join, {
|
||||||
[styles.wait]: waitingForInvite,
|
[styles.wait]: waitingForInvite,
|
||||||
@@ -140,6 +208,7 @@ export const LobbyView: FC<Props> = ({
|
|||||||
onClick={onVideoPress}
|
onClick={onVideoPress}
|
||||||
disabled={muteStates.video.setEnabled === null}
|
disabled={muteStates.video.setEnabled === null}
|
||||||
/>
|
/>
|
||||||
|
{switchCamera && <SwitchCameraButton onClick={switchCamera} />}
|
||||||
<SettingsButton onClick={openSettings} />
|
<SettingsButton onClick={openSettings} />
|
||||||
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { E2eeType } from "../e2ee/e2eeType";
|
|||||||
import { useProfile } from "../profile/useProfile";
|
import { useProfile } from "../profile/useProfile";
|
||||||
import { useMuteStates } from "./MuteStates";
|
import { useMuteStates } from "./MuteStates";
|
||||||
import { useOptInAnalytics } from "../settings/settings";
|
import { useOptInAnalytics } from "../settings/settings";
|
||||||
|
import { Config } from "../config/Config";
|
||||||
|
|
||||||
export const RoomPage: FC = () => {
|
export const RoomPage: FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -185,6 +186,7 @@ export const RoomPage: FC = () => {
|
|||||||
{content}
|
{content}
|
||||||
{/* On Android and iOS, show a prompt to launch the mobile app. */}
|
{/* On Android and iOS, show a prompt to launch the mobile app. */}
|
||||||
{appPrompt &&
|
{appPrompt &&
|
||||||
|
Config.get().app_prompt &&
|
||||||
(platform === "android" || platform === "ios") &&
|
(platform === "android" || platform === "ios") &&
|
||||||
roomId && <AppSelectionModal roomId={roomId} />}
|
roomId && <AppSelectionModal roomId={roomId} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ Please see LICENSE in the repository root for full details.
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
transform: scaleX(-1);
|
|
||||||
background-color: var(--cpd-color-bg-subtle-primary);
|
background-color: var(--cpd-color-bg-subtle-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
video.mirror {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
.avatarContainer {
|
.avatarContainer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -5,18 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react";
|
import { useEffect, useRef, FC, ReactNode } from "react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { usePreviewTracks } from "@livekit/components-react";
|
import { facingModeFromLocalTrack, LocalVideoTrack } from "livekit-client";
|
||||||
import { LocalVideoTrack, Track } from "livekit-client";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
|
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
import styles from "./VideoPreview.module.css";
|
import styles from "./VideoPreview.module.css";
|
||||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
|
||||||
import { MuteStates } from "./MuteStates";
|
import { MuteStates } from "./MuteStates";
|
||||||
import { useInitial } from "../useInitial";
|
|
||||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
|
|
||||||
export type MatrixInfo = {
|
export type MatrixInfo = {
|
||||||
@@ -33,65 +29,18 @@ export type MatrixInfo = {
|
|||||||
interface Props {
|
interface Props {
|
||||||
matrixInfo: MatrixInfo;
|
matrixInfo: MatrixInfo;
|
||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
|
videoTrack: LocalVideoTrack | null;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoPreview: FC<Props> = ({
|
export const VideoPreview: FC<Props> = ({
|
||||||
matrixInfo,
|
matrixInfo,
|
||||||
muteStates,
|
muteStates,
|
||||||
|
videoTrack,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const [previewRef, previewBounds] = useMeasure();
|
const [previewRef, previewBounds] = useMeasure();
|
||||||
|
|
||||||
const devices = useMediaDevices();
|
|
||||||
|
|
||||||
// Capture the audio options as they were when we first mounted, because
|
|
||||||
// we're not doing anything with the audio anyway so we don't need to
|
|
||||||
// re-open the devices when they change (see below).
|
|
||||||
const initialAudioOptions = useInitial(
|
|
||||||
() =>
|
|
||||||
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
|
|
||||||
);
|
|
||||||
|
|
||||||
const localTrackOptions = useMemo(
|
|
||||||
() => ({
|
|
||||||
// The only reason we request audio here is to get the audio permission
|
|
||||||
// request over with at the same time. But changing the audio settings
|
|
||||||
// shouldn't cause this hook to recreate the track, which is why we
|
|
||||||
// reference the initial values here.
|
|
||||||
// We also pass in a clone because livekit mutates the object passed in,
|
|
||||||
// which would cause the devices to be re-opened on the next render.
|
|
||||||
audio: Object.assign({}, initialAudioOptions),
|
|
||||||
video: muteStates.video.enabled && {
|
|
||||||
deviceId: devices.videoInput.selectedId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
initialAudioOptions,
|
|
||||||
devices.videoInput.selectedId,
|
|
||||||
muteStates.video.enabled,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onError = useCallback(
|
|
||||||
(error: Error) => {
|
|
||||||
logger.error("Error while creating preview Tracks:", error);
|
|
||||||
muteStates.audio.setEnabled?.(false);
|
|
||||||
muteStates.video.setEnabled?.(false);
|
|
||||||
},
|
|
||||||
[muteStates.audio, muteStates.video],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tracks = usePreviewTracks(localTrackOptions, onError);
|
|
||||||
|
|
||||||
const videoTrack = useMemo(
|
|
||||||
() =>
|
|
||||||
tracks?.find((t) => t.kind === Track.Kind.Video) as
|
|
||||||
| LocalVideoTrack
|
|
||||||
| undefined,
|
|
||||||
[tracks],
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoEl = useRef<HTMLVideoElement | null>(null);
|
const videoEl = useRef<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -107,6 +56,12 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||||
<video
|
<video
|
||||||
|
className={
|
||||||
|
videoTrack &&
|
||||||
|
facingModeFromLocalTrack(videoTrack).facingMode === "user"
|
||||||
|
? styles.mirror
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
ref={videoEl}
|
ref={videoEl}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
|
|||||||
98
src/room/useSwitchCamera.ts
Normal file
98
src/room/useSwitchCamera.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
merge,
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
import {
|
||||||
|
facingModeFromLocalTrack,
|
||||||
|
LocalVideoTrack,
|
||||||
|
TrackEvent,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||||
|
import { platform } from "../Platform";
|
||||||
|
import { useLatest } from "../useLatest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the user should be shown a button to switch their camera,
|
||||||
|
* producing a callback if so.
|
||||||
|
*/
|
||||||
|
export function useSwitchCamera(
|
||||||
|
video: Observable<LocalVideoTrack | null>,
|
||||||
|
): (() => void) | null {
|
||||||
|
const mediaDevices = useMediaDevices();
|
||||||
|
|
||||||
|
// Produce an observable like the input 'video' observable, except make it
|
||||||
|
// emit whenever the track is muted or the device changes
|
||||||
|
const videoTrack: Observable<LocalVideoTrack | null> = useObservable(
|
||||||
|
(inputs) =>
|
||||||
|
inputs.pipe(
|
||||||
|
switchMap(([video]) => video),
|
||||||
|
switchMap((video) => {
|
||||||
|
if (video === null) return of(null);
|
||||||
|
return merge(
|
||||||
|
fromEvent(video, TrackEvent.Restarted).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => video),
|
||||||
|
),
|
||||||
|
fromEvent(video, TrackEvent.Muted).pipe(map(() => null)),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[video],
|
||||||
|
);
|
||||||
|
|
||||||
|
const switchCamera: Observable<(() => void) | null> = useObservable(
|
||||||
|
(inputs) =>
|
||||||
|
platform === "desktop"
|
||||||
|
? of(null)
|
||||||
|
: inputs.pipe(
|
||||||
|
switchMap(([track]) => track),
|
||||||
|
map((track) => {
|
||||||
|
if (track === null) return null;
|
||||||
|
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||||
|
// If the camera isn't front or back-facing, don't provide a switch
|
||||||
|
// camera shortcut at all
|
||||||
|
if (facingMode !== "user" && facingMode !== "environment")
|
||||||
|
return null;
|
||||||
|
// Restart the track with a camera facing the opposite direction
|
||||||
|
return (): void =>
|
||||||
|
void track
|
||||||
|
.restartTrack({
|
||||||
|
facingMode: facingMode === "user" ? "environment" : "user",
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error("Failed to switch camera", facingMode, e),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[videoTrack],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setVideoInput = useLatest(mediaDevices.videoInput.select);
|
||||||
|
useEffect(() => {
|
||||||
|
// Watch for device changes due to switching the camera and feed them back
|
||||||
|
// into the MediaDeviceContext
|
||||||
|
const subscription = videoTrack.subscribe((track) => {
|
||||||
|
const deviceId = track?.mediaStreamTrack.getSettings().deviceId;
|
||||||
|
if (deviceId !== undefined) setVideoInput.current(deviceId);
|
||||||
|
});
|
||||||
|
return (): void => subscription.unsubscribe();
|
||||||
|
}, [videoTrack, setVideoInput]);
|
||||||
|
|
||||||
|
return useObservableEagerState(switchCamera);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
ECAddonConnectionState,
|
ECAddonConnectionState,
|
||||||
ECConnectionState,
|
ECConnectionState,
|
||||||
} from "../livekit/useECConnectionState";
|
} from "../livekit/useECConnectionState";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
|
||||||
vi.mock("@livekit/components-core");
|
vi.mock("@livekit/components-core");
|
||||||
|
|
||||||
@@ -158,7 +159,9 @@ function withCallViewModel(
|
|||||||
getMember: (userId) => members.get(userId) ?? null,
|
getMember: (userId) => members.get(userId) ?? null,
|
||||||
}),
|
}),
|
||||||
mockLivekitRoom({ localParticipant }),
|
mockLivekitRoom({ localParticipant }),
|
||||||
true,
|
{
|
||||||
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
|
},
|
||||||
connectionState,
|
connectionState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
Room as LivekitRoom,
|
Room as LivekitRoom,
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
|
LocalVideoTrack,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
RemoteParticipant,
|
RemoteParticipant,
|
||||||
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import {
|
import {
|
||||||
Room as MatrixRoom,
|
Room as MatrixRoom,
|
||||||
@@ -58,6 +60,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LocalUserMediaViewModel,
|
LocalUserMediaViewModel,
|
||||||
MediaViewModel,
|
MediaViewModel,
|
||||||
|
observeTrackReference,
|
||||||
RemoteUserMediaViewModel,
|
RemoteUserMediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
UserMediaViewModel,
|
UserMediaViewModel,
|
||||||
@@ -67,6 +70,7 @@ import { ObservableScope } from "./ObservableScope";
|
|||||||
import { duplicateTiles } from "../settings/settings";
|
import { duplicateTiles } from "../settings/settings";
|
||||||
import { isFirefox } from "../Platform";
|
import { isFirefox } from "../Platform";
|
||||||
import { setPipEnabled } from "../controls";
|
import { setPipEnabled } from "../controls";
|
||||||
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
|
|
||||||
// How long we wait after a focus switch before showing the real participant
|
// How long we wait after a focus switch before showing the real participant
|
||||||
// list again
|
// list again
|
||||||
@@ -167,20 +171,20 @@ class UserMedia {
|
|||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
callEncrypted: boolean,
|
encryptionSystem: EncryptionSystem,
|
||||||
) {
|
) {
|
||||||
this.vm = participant.isLocal
|
this.vm = participant.isLocal
|
||||||
? new LocalUserMediaViewModel(
|
? new LocalUserMediaViewModel(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
participant as LocalParticipant,
|
participant as LocalParticipant,
|
||||||
callEncrypted,
|
encryptionSystem,
|
||||||
)
|
)
|
||||||
: new RemoteUserMediaViewModel(
|
: new RemoteUserMediaViewModel(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
participant as RemoteParticipant,
|
participant as RemoteParticipant,
|
||||||
callEncrypted,
|
encryptionSystem,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.speaker = this.vm.speaking.pipe(
|
this.speaker = this.vm.speaking.pipe(
|
||||||
@@ -223,9 +227,14 @@ class ScreenShare {
|
|||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
callEncrypted: boolean,
|
encryptionSystem: EncryptionSystem,
|
||||||
) {
|
) {
|
||||||
this.vm = new ScreenShareViewModel(id, member, participant, callEncrypted);
|
this.vm = new ScreenShareViewModel(
|
||||||
|
id,
|
||||||
|
member,
|
||||||
|
participant,
|
||||||
|
encryptionSystem,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
@@ -259,6 +268,17 @@ function findMatrixMember(
|
|||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
|
public readonly localVideo: Observable<LocalVideoTrack | null> =
|
||||||
|
observeTrackReference(
|
||||||
|
this.livekitRoom.localParticipant,
|
||||||
|
Track.Source.Camera,
|
||||||
|
).pipe(
|
||||||
|
map((trackRef) => {
|
||||||
|
const track = trackRef.publication?.track;
|
||||||
|
return track instanceof LocalVideoTrack ? track : null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly rawRemoteParticipants = connectedParticipantsObserver(
|
private readonly rawRemoteParticipants = connectedParticipantsObserver(
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
).pipe(this.scope.state());
|
).pipe(this.scope.state());
|
||||||
@@ -349,7 +369,12 @@ export class CallViewModel extends ViewModel {
|
|||||||
yield [
|
yield [
|
||||||
userMediaId,
|
userMediaId,
|
||||||
prevItems.get(userMediaId) ??
|
prevItems.get(userMediaId) ??
|
||||||
new UserMedia(userMediaId, member, p, this.encrypted),
|
new UserMedia(
|
||||||
|
userMediaId,
|
||||||
|
member,
|
||||||
|
p,
|
||||||
|
this.encryptionSystem,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (p.isScreenShareEnabled) {
|
if (p.isScreenShareEnabled) {
|
||||||
@@ -357,7 +382,12 @@ export class CallViewModel extends ViewModel {
|
|||||||
yield [
|
yield [
|
||||||
screenShareId,
|
screenShareId,
|
||||||
prevItems.get(screenShareId) ??
|
prevItems.get(screenShareId) ??
|
||||||
new ScreenShare(screenShareId, member, p, this.encrypted),
|
new ScreenShare(
|
||||||
|
screenShareId,
|
||||||
|
member,
|
||||||
|
p,
|
||||||
|
this.encryptionSystem,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,7 +845,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||||
private readonly matrixRoom: MatrixRoom,
|
private readonly matrixRoom: MatrixRoom,
|
||||||
private readonly livekitRoom: LivekitRoom,
|
private readonly livekitRoom: LivekitRoom,
|
||||||
private readonly encrypted: boolean,
|
private readonly encryptionSystem: EncryptionSystem,
|
||||||
private readonly connectionState: Observable<ECConnectionState>,
|
private readonly connectionState: Observable<ECConnectionState>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import { ViewModel } from "./ViewModel";
|
|||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import { alwaysShowSelf } from "../settings/settings";
|
import { alwaysShowSelf } from "../settings/settings";
|
||||||
import { accumulate } from "../utils/observable";
|
import { accumulate } from "../utils/observable";
|
||||||
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
|
||||||
// TODO: Move this naming logic into the view model
|
// TODO: Move this naming logic into the view model
|
||||||
export function useDisplayName(vm: MediaViewModel): string {
|
export function useDisplayName(vm: MediaViewModel): string {
|
||||||
@@ -65,7 +67,7 @@ export function useDisplayName(vm: MediaViewModel): string {
|
|||||||
return displayName;
|
return displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function observeTrackReference(
|
export function observeTrackReference(
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Observable<TrackReferenceOrPlaceholder> {
|
): Observable<TrackReferenceOrPlaceholder> {
|
||||||
@@ -105,7 +107,7 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
// member object internal
|
// member object internal
|
||||||
public readonly member: RoomMember | undefined,
|
public readonly member: RoomMember | undefined,
|
||||||
protected readonly participant: LocalParticipant | RemoteParticipant,
|
protected readonly participant: LocalParticipant | RemoteParticipant,
|
||||||
callEncrypted: boolean,
|
encryptionSystem: EncryptionSystem,
|
||||||
audioSource: AudioSource,
|
audioSource: AudioSource,
|
||||||
videoSource: VideoSource,
|
videoSource: VideoSource,
|
||||||
) {
|
) {
|
||||||
@@ -119,7 +121,7 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
this.unencryptedWarning = combineLatest(
|
this.unencryptedWarning = combineLatest(
|
||||||
[audio, this.video],
|
[audio, this.video],
|
||||||
(a, v) =>
|
(a, v) =>
|
||||||
callEncrypted &&
|
encryptionSystem.kind !== E2eeType.NONE &&
|
||||||
(a.publication?.isEncrypted === false ||
|
(a.publication?.isEncrypted === false ||
|
||||||
v.publication?.isEncrypted === false),
|
v.publication?.isEncrypted === false),
|
||||||
).pipe(this.scope.state());
|
).pipe(this.scope.state());
|
||||||
@@ -168,13 +170,13 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
callEncrypted: boolean,
|
encryptionSystem: EncryptionSystem,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
participant,
|
participant,
|
||||||
callEncrypted,
|
encryptionSystem,
|
||||||
Track.Source.Microphone,
|
Track.Source.Microphone,
|
||||||
Track.Source.Camera,
|
Track.Source.Camera,
|
||||||
);
|
);
|
||||||
@@ -225,9 +227,9 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant,
|
participant: LocalParticipant,
|
||||||
callEncrypted: boolean,
|
encryptionSystem: EncryptionSystem,
|
||||||
) {
|
) {
|
||||||
super(id, member, participant, callEncrypted);
|
super(id, member, participant, encryptionSystem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +287,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
callEncrypted: boolean,
|
encryptionSystem: EncryptionSystem,
|
||||||
) {
|
) {
|
||||||
super(id, member, participant, callEncrypted);
|
super(id, member, participant, encryptionSystem);
|
||||||
|
|
||||||
// Sync the local volume with LiveKit
|
// Sync the local volume with LiveKit
|
||||||
this.localVolume
|
this.localVolume
|
||||||
@@ -318,13 +320,13 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
|||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
callEncrypted: boolean,
|
encryptionSystem: EncryptionSystem,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
participant,
|
participant,
|
||||||
callEncrypted,
|
encryptionSystem,
|
||||||
Track.Source.ScreenShareAudio,
|
Track.Source.ScreenShareAudio,
|
||||||
Track.Source.ScreenShare,
|
Track.Source.ScreenShare,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ UserMediaTile.displayName = "UserMediaTile";
|
|||||||
|
|
||||||
interface LocalUserMediaTileProps extends TileProps {
|
interface LocalUserMediaTileProps extends TileProps {
|
||||||
vm: LocalUserMediaViewModel;
|
vm: LocalUserMediaViewModel;
|
||||||
onOpenProfile: () => void;
|
onOpenProfile: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||||
@@ -203,11 +203,13 @@ const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
menuEnd={
|
menuEnd={
|
||||||
<MenuItem
|
onOpenProfile && (
|
||||||
Icon={UserProfileIcon}
|
<MenuItem
|
||||||
label={t("common.profile")}
|
Icon={UserProfileIcon}
|
||||||
onSelect={onOpenProfile}
|
label={t("common.profile")}
|
||||||
/>
|
onSelect={onOpenProfile}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -280,7 +282,7 @@ RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
|||||||
|
|
||||||
interface GridTileProps {
|
interface GridTileProps {
|
||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
onOpenProfile: () => void;
|
onOpenProfile: (() => void) | null;
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships
|
|||||||
import { useClientState } from "./ClientContext";
|
import { useClientState } from "./ClientContext";
|
||||||
import {
|
import {
|
||||||
ECallReactionEventContent,
|
ECallReactionEventContent,
|
||||||
|
ElementCallReactionEventType,
|
||||||
GenericReaction,
|
GenericReaction,
|
||||||
ReactionOption,
|
ReactionOption,
|
||||||
ReactionSet,
|
ReactionSet,
|
||||||
@@ -195,7 +196,7 @@ export const ReactionsProvider = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.getType() === "io.element.call.reaction") {
|
if (event.getType() === ElementCallReactionEventType) {
|
||||||
// TODO: Validate content.
|
// TODO: Validate content.
|
||||||
const content: ECallReactionEventContent = event.getContent();
|
const content: ECallReactionEventContent = event.getContent();
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
LocalUserMediaViewModel,
|
LocalUserMediaViewModel,
|
||||||
RemoteUserMediaViewModel,
|
RemoteUserMediaViewModel,
|
||||||
} from "../state/MediaViewModel";
|
} from "../state/MediaViewModel";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
|
||||||
export function withFakeTimers(continuation: () => void): void {
|
export function withFakeTimers(continuation: () => void): void {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -122,7 +123,9 @@ export async function withLocalMedia(
|
|||||||
"local",
|
"local",
|
||||||
mockMember(member),
|
mockMember(member),
|
||||||
mockLocalParticipant({}),
|
mockLocalParticipant({}),
|
||||||
true,
|
{
|
||||||
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await continuation(vm);
|
await continuation(vm);
|
||||||
@@ -153,7 +156,9 @@ export async function withRemoteMedia(
|
|||||||
"remote",
|
"remote",
|
||||||
mockMember(member),
|
mockMember(member),
|
||||||
mockRemoteParticipant(participant),
|
mockRemoteParticipant(participant),
|
||||||
true,
|
{
|
||||||
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await continuation(vm);
|
await continuation(vm);
|
||||||
|
|||||||
@@ -5676,9 +5676,9 @@ lines-and-columns@^1.1.6:
|
|||||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||||
|
|
||||||
livekit-client@^2.5.7:
|
livekit-client@^2.5.7:
|
||||||
version "2.5.10"
|
version "2.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.5.10.tgz#ce0b70fe9b199760a159ffb99536bc3b6492ab64"
|
resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.6.0.tgz#7506838fb59937150208b8e593701cde19f3fb75"
|
||||||
integrity sha512-H7EeIb19LAH8ejlvhh0JWtWkvXDan6Yf3bpFGlDMb54uPmyRgBY+McfgQsFgJCB9WJL0X+GYUoV1Cmnn8iAoIQ==
|
integrity sha512-hpxNBtyWIFCefoHjHoSjqPCw3m7AfSJVcVZw6rMsqds4u+dSpWLfYkglWP8JuPGUIssyOsZm/+bV3gBWfuOGGQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@livekit/mutex" "1.0.0"
|
"@livekit/mutex" "1.0.0"
|
||||||
"@livekit/protocol" "1.24.0"
|
"@livekit/protocol" "1.24.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user