mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-31 07:00:26 +00:00
Merge branch 'livekit' into toger5/tiles_based_on_rtc_member
This commit is contained in:
@@ -106,7 +106,7 @@ rc_message:
|
||||
|
||||
MSC3266 allows to request a room summary of rooms you are not joined. The
|
||||
summary contains the room join rules. We need that to decide if the user gets
|
||||
prompted with the option to knock ("ask to join"), a cannot join error or the
|
||||
prompted with the option to knock ("Request to join call"), a cannot join error or the
|
||||
join view.
|
||||
|
||||
Element Call requires a Livekit SFU alongside a [Livekit JWT
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^1.9.1",
|
||||
"@vector-im/compound-web": "element-hq/compound-web#46cf2d94d9c9b6d25e80ef0e785f3a929ed040ea",
|
||||
"@vector-im/compound-web": "^7.2.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.0.1",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
@@ -87,8 +87,8 @@
|
||||
"livekit-client": "^2.5.7",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6971e7bebaad643c233e5057da7a0d42441c0789",
|
||||
"matrix-widget-api": "^1.8.2",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#8e9a04cdec0f88fc876bbbf406db55b0677f005d",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"pako": "^2.0.4",
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
"call_ended_heading": "Anruf beendet",
|
||||
"failed_heading": "Beitreten fehlgeschlagen",
|
||||
"failed_text": "Anruf nicht gefunden oder Beitritt nicht erlaubt",
|
||||
"knock_reject_body": "Die Mitglieder des Raums haben Deine Beitrittsanfrage abgelehnt.",
|
||||
"knock_reject_heading": "Beitritt nicht erlaubt",
|
||||
"knock_reject_body": "Die Teilnahmeanfrage wurde abgelehnt.",
|
||||
"knock_reject_heading": "Zugriff verweigert",
|
||||
"reason": "Grund"
|
||||
},
|
||||
"hangup_button_label": "Anruf beenden",
|
||||
@@ -100,11 +100,11 @@
|
||||
"layout_grid_label": "Raster",
|
||||
"layout_spotlight_label": "Fokus",
|
||||
"lobby": {
|
||||
"ask_to_join": "Beitritt anfragen",
|
||||
"ask_to_join": "Teilnahmeanfrage senden",
|
||||
"join_as_guest": "Als Gast beitreten",
|
||||
"join_button": "Anruf beitreten",
|
||||
"leave_button": "Zurück zu kürzlichen Anrufen",
|
||||
"waiting_for_invite": "Anfrage gesendet"
|
||||
"waiting_for_invite": "Anfrage gesendet! Warten auf Teilnahmefreigabe …"
|
||||
},
|
||||
"log_in": "Anmelden",
|
||||
"logging_in": "Anmelden …",
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
"call_ended_heading": "Call ended",
|
||||
"failed_heading": "Failed to join",
|
||||
"failed_text": "Call not found or is not accessible.",
|
||||
"knock_reject_body": "The room members declined your request to join.",
|
||||
"knock_reject_heading": "Not allowed to join",
|
||||
"knock_reject_body": "Your request to join was declined.",
|
||||
"knock_reject_heading": "Access denied",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"hangup_button_label": "End call",
|
||||
@@ -100,11 +100,11 @@
|
||||
"layout_grid_label": "Grid",
|
||||
"layout_spotlight_label": "Spotlight",
|
||||
"lobby": {
|
||||
"ask_to_join": "Ask to join call",
|
||||
"ask_to_join": "Request to join call",
|
||||
"join_as_guest": "Join as guest",
|
||||
"join_button": "Join call",
|
||||
"leave_button": "Back to recents",
|
||||
"waiting_for_invite": "Request sent"
|
||||
"waiting_for_invite": "Request sent! Waiting for permission to join…"
|
||||
},
|
||||
"log_in": "Log In",
|
||||
"logging_in": "Logging in…",
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { FC, useCallback } from "react";
|
||||
import { Root, Track, Range, Thumb } from "@radix-ui/react-slider";
|
||||
import classNames from "classnames";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./Slider.module.css";
|
||||
|
||||
@@ -66,7 +67,10 @@ export const Slider: FC<Props> = ({
|
||||
<Track className={styles.track}>
|
||||
<Range className={styles.highlight} />
|
||||
</Track>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
{/* Note: This is expected not to be visible on mobile.*/}
|
||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
</Tooltip>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,16 @@
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.reactionButton {
|
||||
padding: 1em;
|
||||
font-size: 1em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
min-block-size: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.verticalSeperator {
|
||||
background-color: var(--cpd-color-gray-800);
|
||||
width: 1px;
|
||||
|
||||
@@ -96,6 +96,30 @@ export interface ConfigOptions {
|
||||
* Note that this can additionally be disabled by the app's URL parameters.
|
||||
*/
|
||||
app_prompt?: boolean;
|
||||
|
||||
/**
|
||||
* These are low level options that are used to configure the MatrixRTC session.
|
||||
* Take care when changing these options.
|
||||
*/
|
||||
matrix_rtc_session?: {
|
||||
/**
|
||||
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
|
||||
* when someone leaves a call.
|
||||
*/
|
||||
key_rotation_on_leave_delay?: number;
|
||||
|
||||
/**
|
||||
* How often (in milliseconds) keep-alive messages should be sent to the server for
|
||||
* the MatrixRTC membership event.
|
||||
*/
|
||||
membership_keep_alive_period?: number;
|
||||
|
||||
/**
|
||||
* How long (in milliseconds) after the last keep-alive the server should expire the
|
||||
* MatrixRTC membership event.
|
||||
*/
|
||||
membership_server_side_expiry_timeout?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Overrides members from ConfigOptions that are always provided by the
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { Link } from "../button/Link";
|
||||
import {
|
||||
useLoadGroupCall,
|
||||
GroupCallStatus,
|
||||
CallTerminatedMessage,
|
||||
} from "./useLoadGroupCall";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomIdOrAlias: string;
|
||||
viaServers: string[];
|
||||
children: (groupCallState: GroupCallStatus) => JSX.Element;
|
||||
}
|
||||
|
||||
export function GroupCallLoader({
|
||||
client,
|
||||
roomIdOrAlias,
|
||||
viaServers,
|
||||
children,
|
||||
}: Props): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
case "waitForInvite":
|
||||
case "canKnock":
|
||||
return children(groupCallState);
|
||||
case "loading":
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("common.loading")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
case "failed":
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{groupCallState.error.message}</Heading>
|
||||
<Text>{groupCallState.error.messageBody}</Text>
|
||||
{groupCallState.error.reason && (
|
||||
<>
|
||||
{t("group_call_loader.reason")}:
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,29 +177,37 @@ export const GroupCallView: FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (widget && preload && skipLobby) {
|
||||
// In preload mode without lobby we wait for a join action before entering
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else if (widget && !preload && skipLobby) {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
if (skipLobby) {
|
||||
if (widget) {
|
||||
if (preload) {
|
||||
// In preload mode without lobby we wait for a join action before entering
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(
|
||||
ev.detail.data as unknown as JoinCallData,
|
||||
);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
void enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
}
|
||||
}
|
||||
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||
|
||||
|
||||
@@ -180,48 +180,3 @@ Please see LICENSE in the repository root for full details.
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.floatingReaction {
|
||||
position: relative;
|
||||
display: inline;
|
||||
z-index: 2;
|
||||
font-size: 32pt;
|
||||
/* Reactions are "active" for 3 seconds (as per REACTION_ACTIVE_TIME_MS), give a bit more time for it to fade out. */
|
||||
animation-duration: 4s;
|
||||
animation-name: reaction-up;
|
||||
width: fit-content;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes reaction-up {
|
||||
from {
|
||||
opacity: 1;
|
||||
translate: 100vw 0;
|
||||
scale: 200%;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
translate: 100vw -100vh;
|
||||
scale: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
@keyframes reaction-up-reduced {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.floatingReaction {
|
||||
font-size: 48pt;
|
||||
animation-name: reaction-up-reduced;
|
||||
top: calc(-50vh + (48pt / 2));
|
||||
left: calc(50vw - (48pt / 2)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,8 @@ import handSoundOgg from "../sound/raise_hand.ogg?url";
|
||||
import handSoundMp3 from "../sound/raise_hand.mp3?url";
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { useSwitchCamera } from "./useSwitchCamera";
|
||||
import {
|
||||
soundEffectVolumeSetting,
|
||||
showReactions,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { soundEffectVolumeSetting, useSetting } from "../settings/settings";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -180,27 +177,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const [shouldShowReactions] = useSetting(showReactions);
|
||||
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
||||
const { supportsReactions, raisedHands, reactions } = useReactions();
|
||||
const { supportsReactions, raisedHands } = useReactions();
|
||||
const raisedHandCount = useMemo(
|
||||
() => Object.keys(raisedHands).length,
|
||||
[raisedHands],
|
||||
);
|
||||
const previousRaisedHandCount = useDeferredValue(raisedHandCount);
|
||||
|
||||
const reactionsIcons = useMemo(
|
||||
() =>
|
||||
shouldShowReactions
|
||||
? Object.entries(reactions).map(([sender, { emoji }]) => ({
|
||||
sender,
|
||||
emoji,
|
||||
startX: -Math.ceil(Math.random() * 50) - 25,
|
||||
}))
|
||||
: [],
|
||||
[shouldShowReactions, reactions],
|
||||
);
|
||||
|
||||
useWakeLock();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -686,15 +670,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<source src={handSoundMp3} type="audio/mpeg" />
|
||||
</audio>
|
||||
<ReactionsAudioRenderer />
|
||||
{reactionsIcons.map(({ sender, emoji, startX }) => (
|
||||
<span
|
||||
style={{ left: `${startX}vw` }}
|
||||
className={styles.floatingReaction}
|
||||
key={sender}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
<ReactionsOverlay />
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
<>
|
||||
|
||||
54
src/room/ReactionsOverlay.module.css
Normal file
54
src/room/ReactionsOverlay.module.css
Normal file
@@ -0,0 +1,54 @@
|
||||
.container {
|
||||
position: absolute;
|
||||
display: inline;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.reaction {
|
||||
font-size: 32pt;
|
||||
/* Reactions are "active" for 3 seconds (as per REACTION_ACTIVE_TIME_MS), give a bit more time for it to fade out. */
|
||||
animation-duration: 4s;
|
||||
animation-name: reaction-up;
|
||||
width: fit-content;
|
||||
position: relative;
|
||||
top: 80vh;
|
||||
}
|
||||
|
||||
@keyframes reaction-up {
|
||||
from {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
scale: 200%;
|
||||
top: 80vh;
|
||||
}
|
||||
|
||||
to {
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
scale: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
@keyframes reaction-up-reduced {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction {
|
||||
font-size: 48pt;
|
||||
animation-name: reaction-up-reduced;
|
||||
top: calc(-50vh + (48pt / 2));
|
||||
left: calc(50vw - (48pt / 2)) !important;
|
||||
}
|
||||
}
|
||||
120
src/room/ReactionsOverlay.test.tsx
Normal file
120
src/room/ReactionsOverlay.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { act, ReactNode } from "react";
|
||||
import { afterEach } from "node:test";
|
||||
|
||||
import {
|
||||
MockRoom,
|
||||
MockRTCSession,
|
||||
TestReactionsWrapper,
|
||||
} from "../utils/testReactions";
|
||||
import { showReactions } from "../settings/settings";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { ReactionSet } from "../reactions";
|
||||
|
||||
const memberUserIdAlice = "@alice:example.org";
|
||||
const memberUserIdBob = "@bob:example.org";
|
||||
const memberUserIdCharlie = "@charlie:example.org";
|
||||
const memberEventAlice = "$membership-alice:example.org";
|
||||
const memberEventBob = "$membership-bob:example.org";
|
||||
const memberEventCharlie = "$membership-charlie:example.org";
|
||||
|
||||
const membership: Record<string, string> = {
|
||||
[memberEventAlice]: memberUserIdAlice,
|
||||
[memberEventBob]: memberUserIdBob,
|
||||
[memberEventCharlie]: memberUserIdCharlie,
|
||||
};
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
}: {
|
||||
rtcSession: MockRTCSession;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TestReactionsWrapper rtcSession={rtcSession}>
|
||||
<ReactionsOverlay />
|
||||
</TestReactionsWrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
showReactions.setValue(showReactions.defaultValue);
|
||||
});
|
||||
|
||||
test("defaults to showing no reactions", () => {
|
||||
showReactions.setValue(true);
|
||||
const rtcSession = new MockRTCSession(
|
||||
new MockRoom(memberUserIdAlice),
|
||||
membership,
|
||||
);
|
||||
const { container } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
expect(container.getElementsByTagName("span")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("shows a reaction when sent", () => {
|
||||
showReactions.setValue(true);
|
||||
const reaction = ReactionSet[0];
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByRole } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reaction, membership);
|
||||
});
|
||||
const span = getByRole("presentation");
|
||||
expect(getByRole("presentation")).toBeTruthy();
|
||||
expect(span.innerHTML).toEqual(reaction.emoji);
|
||||
});
|
||||
|
||||
test("shows two of the same reaction when sent", () => {
|
||||
showReactions.setValue(true);
|
||||
const reaction = ReactionSet[0];
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getAllByRole } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reaction, membership);
|
||||
});
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventBob, reaction, membership);
|
||||
});
|
||||
expect(getAllByRole("presentation")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("shows two different reactions when sent", () => {
|
||||
showReactions.setValue(true);
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const [reactionA, reactionB] = ReactionSet;
|
||||
const { getAllByRole } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reactionA, membership);
|
||||
});
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventBob, reactionB, membership);
|
||||
});
|
||||
const [reactionElementA, reactionElementB] = getAllByRole("presentation");
|
||||
expect(reactionElementA.innerHTML).toEqual(reactionA.emoji);
|
||||
expect(reactionElementB.innerHTML).toEqual(reactionB.emoji);
|
||||
});
|
||||
|
||||
test("hides reactions when reaction animations are disabled", () => {
|
||||
showReactions.setValue(false);
|
||||
const reaction = ReactionSet[0];
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reaction, membership);
|
||||
});
|
||||
const { container } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
expect(container.getElementsByTagName("span")).toHaveLength(0);
|
||||
});
|
||||
50
src/room/ReactionsOverlay.tsx
Normal file
50
src/room/ReactionsOverlay.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ReactNode, useMemo } from "react";
|
||||
|
||||
import { useReactions } from "../useReactions";
|
||||
import {
|
||||
showReactions as showReactionsSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import styles from "./ReactionsOverlay.module.css";
|
||||
|
||||
export function ReactionsOverlay(): ReactNode {
|
||||
const { reactions } = useReactions();
|
||||
const [showReactions] = useSetting(showReactionsSetting);
|
||||
const reactionsIcons = useMemo(
|
||||
() =>
|
||||
showReactions
|
||||
? Object.entries(reactions).map(([sender, { emoji }]) => ({
|
||||
sender,
|
||||
emoji,
|
||||
startX: Math.ceil(Math.random() * 80) + 10,
|
||||
}))
|
||||
: [],
|
||||
[showReactions, reactions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{reactionsIcons.map(({ sender, emoji, startX }) => (
|
||||
<span
|
||||
// Reactions effects are considered presentation elements. The reaction
|
||||
// is also present on the sender's tile, which assistive technology can
|
||||
// read from instead.
|
||||
role="presentation"
|
||||
style={{ left: `${startX}vw` }}
|
||||
className={styles.reaction}
|
||||
// A sender can only send one emoji at a time.
|
||||
key={sender}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
|
||||
import { FC, useEffect, useState, ReactNode, useRef } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
import { ErrorView, FullScreenView, LoadingView } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { GroupCallLoader } from "./GroupCallLoader";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
@@ -21,13 +22,14 @@ import { HomePage } from "../home/HomePage";
|
||||
import { platform } from "../Platform";
|
||||
import { AppSelectionModal } from "./AppSelectionModal";
|
||||
import { widget } from "../widget";
|
||||
import { GroupCallStatus } from "./useLoadGroupCall";
|
||||
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
import { Config } from "../config/Config";
|
||||
import { Link } from "../button/Link";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const {
|
||||
@@ -53,6 +55,7 @@ export const RoomPage: FC = () => {
|
||||
useClientLegacy();
|
||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
const muteStates = useMuteStates();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,82 +85,112 @@ export const RoomPage: FC = () => {
|
||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||
}, [optInAnalytics, setOptInAnalytics]);
|
||||
|
||||
const groupCallView = useCallback(
|
||||
(groupCallState: GroupCallStatus): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
const wasInWaitForInviteState = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) {
|
||||
logger.log("Play join sound 'Not yet implemented'");
|
||||
}
|
||||
}, [groupCallState.kind]);
|
||||
|
||||
const groupCallView = (): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
wasInWaitForInviteState.current =
|
||||
wasInWaitForInviteState.current ||
|
||||
groupCallState.kind === "waitForInvite";
|
||||
const knock =
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
||||
const label: string | JSX.Element =
|
||||
groupCallState.kind === "canKnock" ? (
|
||||
t("lobby.ask_to_join")
|
||||
) : (
|
||||
<>
|
||||
{t("lobby.waiting_for_invite")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
const knock =
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
||||
const label: string | JSX.Element =
|
||||
groupCallState.kind === "canKnock" ? (
|
||||
t("lobby.ask_to_join")
|
||||
) : (
|
||||
<>
|
||||
{t("lobby.waiting_for_invite")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary[
|
||||
"im.nheko.summary.encryption"
|
||||
]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
return (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary["im.nheko.summary.encryption"]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
client,
|
||||
passwordlessUser,
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
hideHeader,
|
||||
muteStates,
|
||||
t,
|
||||
userDisplayName,
|
||||
avatarUrl,
|
||||
],
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("common.loading")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
case "failed":
|
||||
wasInWaitForInviteState.current = false;
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{groupCallState.error.message}</Heading>
|
||||
<Text>{groupCallState.error.messageBody}</Text>
|
||||
{groupCallState.error.reason && (
|
||||
<>
|
||||
{t("group_call_loader.reason")}:
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
}
|
||||
};
|
||||
|
||||
let content: ReactNode;
|
||||
if (loading || isRegistering) {
|
||||
@@ -170,15 +203,7 @@ export const RoomPage: FC = () => {
|
||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||
content = <HomePage />;
|
||||
} else {
|
||||
content = (
|
||||
<GroupCallLoader
|
||||
client={client}
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
viaServers={viaServers}
|
||||
>
|
||||
{groupCallView}
|
||||
</GroupCallLoader>
|
||||
);
|
||||
content = groupCallView();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -117,8 +117,8 @@ export class CallTerminatedMessage extends Error {
|
||||
}
|
||||
|
||||
export const useLoadGroupCall = (
|
||||
client: MatrixClient,
|
||||
roomIdOrAlias: string,
|
||||
client: MatrixClient | undefined,
|
||||
roomIdOrAlias: string | null,
|
||||
viaServers: string[],
|
||||
): GroupCallStatus => {
|
||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||
@@ -159,6 +159,9 @@ export const useLoadGroupCall = (
|
||||
?.getContent().reason;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !roomIdOrAlias) {
|
||||
return;
|
||||
}
|
||||
const getRoomByAlias = async (alias: string): Promise<Room> => {
|
||||
// We lowercase the localpart when we create the room, so we must lowercase
|
||||
// it here too (we just do the whole alias). We can't do the same to room IDs
|
||||
|
||||
@@ -98,8 +98,9 @@ export async function enterRTCSession(
|
||||
|
||||
// right now we assume everything is a room-scoped call
|
||||
const livekitAlias = rtcSession.room.roomId;
|
||||
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
||||
const useDeviceSessionMemberEvents =
|
||||
Config.get().features?.feature_use_device_session_member_events;
|
||||
features?.feature_use_device_session_member_events;
|
||||
rtcSession.joinRoomSession(
|
||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
||||
makeActiveFocus(),
|
||||
@@ -108,6 +109,11 @@ export async function enterRTCSession(
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
membershipServerSideExpiryTimeout:
|
||||
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
|
||||
membershipKeepAlivePeriod:
|
||||
matrixRtcSessionConfig?.membership_keep_alive_period,
|
||||
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export const playReactionsSound = new Setting<boolean>(
|
||||
|
||||
export const soundEffectVolumeSetting = new Setting<number>(
|
||||
"sound-effect-volume",
|
||||
1,
|
||||
0.5,
|
||||
);
|
||||
|
||||
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -801,27 +801,6 @@ export class CallViewModel extends ViewModel {
|
||||
this.gridModeUserSelection.next(value);
|
||||
}
|
||||
|
||||
private readonly oneOnOne: Observable<
|
||||
| { local: LocalUserMediaViewModel; remote: RemoteUserMediaViewModel }
|
||||
| undefined
|
||||
> = combineLatest([this.grid, this.screenShares], (grid, screenShares) => {
|
||||
if (grid.length !== 2 || screenShares.length !== 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const local = grid.find((vm) => vm.local);
|
||||
const remote = grid.find((vm) => !vm.local);
|
||||
|
||||
if (!local || !remote) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
local,
|
||||
remote,
|
||||
};
|
||||
});
|
||||
|
||||
private readonly gridLayout: Observable<LayoutMedia> = combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) => ({
|
||||
@@ -856,6 +835,24 @@ export class CallViewModel extends ViewModel {
|
||||
pip: pip ?? undefined,
|
||||
}));
|
||||
|
||||
private readonly oneOnOneLayout: Observable<LayoutMedia | null> =
|
||||
this.mediaItems.pipe(
|
||||
map((mediaItems) => {
|
||||
if (mediaItems.length !== 2) return null;
|
||||
const local = mediaItems.find((vm) => vm.vm.local)!
|
||||
.vm as LocalUserMediaViewModel;
|
||||
const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as
|
||||
| RemoteUserMediaViewModel
|
||||
| undefined;
|
||||
// There might not be a remote tile if there are screen shares, or if
|
||||
// only the local user is in the call and they're using the duplicate
|
||||
// tiles option
|
||||
if (remote === undefined) return null;
|
||||
|
||||
return { type: "one-on-one", local, remote };
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly pipLayout: Observable<LayoutMedia> = this.spotlight.pipe(
|
||||
map((spotlight) => ({ type: "pip", spotlight })),
|
||||
);
|
||||
@@ -871,16 +868,9 @@ export class CallViewModel extends ViewModel {
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return this.oneOnOne.pipe(
|
||||
switchMap(
|
||||
(oneOnOne): Observable<LayoutMedia> =>
|
||||
oneOnOne
|
||||
? of({
|
||||
type: "one-on-one",
|
||||
local: oneOnOne.local,
|
||||
remote: oneOnOne.remote,
|
||||
})
|
||||
: this.gridLayout,
|
||||
return this.oneOnOneLayout.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? this.gridLayout : of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "spotlight":
|
||||
@@ -895,20 +885,20 @@ export class CallViewModel extends ViewModel {
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return this.oneOnOne.pipe(
|
||||
return this.oneOnOneLayout.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne
|
||||
? // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
this.spotlightExpandedLayout
|
||||
: combineLatest(
|
||||
oneOnOne === null
|
||||
? combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
? this.spotlightPortraitLayout
|
||||
: this.gridLayout,
|
||||
).pipe(switchAll()),
|
||||
).pipe(switchAll())
|
||||
: // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
this.spotlightExpandedLayout,
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
|
||||
@@ -195,11 +195,12 @@ export const ReactionsProvider = ({
|
||||
// Skip any event without a sender or event ID.
|
||||
if (!sender || !reactionEventId) return;
|
||||
|
||||
room.client
|
||||
.decryptEventIfNeeded(event)
|
||||
.catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e));
|
||||
if (event.isBeingDecrypted() || event.isDecryptionFailure()) return;
|
||||
|
||||
if (event.getType() === ElementCallReactionEventType) {
|
||||
room.client
|
||||
.decryptEventIfNeeded(event)
|
||||
.catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e));
|
||||
if (event.isBeingDecrypted() || event.isDecryptionFailure()) return;
|
||||
const content: ECallReactionEventContent = event.getContent();
|
||||
|
||||
const membershipEventId = content?.["m.relates_to"]?.event_id;
|
||||
|
||||
@@ -123,6 +123,7 @@ export async function initClient(
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
store: new MemoryStore(),
|
||||
});
|
||||
|
||||
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||
|
||||
Reference in New Issue
Block a user