mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +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
|
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
|
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.
|
join view.
|
||||||
|
|
||||||
Element Call requires a Livekit SFU alongside a [Livekit JWT
|
Element Call requires a Livekit SFU alongside a [Livekit JWT
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
"@use-gesture/react": "^10.2.11",
|
"@use-gesture/react": "^10.2.11",
|
||||||
"@vector-im/compound-design-tokens": "^1.9.1",
|
"@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-basic-ssl": "^1.0.1",
|
||||||
"@vitejs/plugin-react": "^4.0.1",
|
"@vitejs/plugin-react": "^4.0.1",
|
||||||
"@vitest/coverage-v8": "^2.0.5",
|
"@vitest/coverage-v8": "^2.0.5",
|
||||||
@@ -87,8 +87,8 @@
|
|||||||
"livekit-client": "^2.5.7",
|
"livekit-client": "^2.5.7",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"loglevel": "^1.9.1",
|
"loglevel": "^1.9.1",
|
||||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6971e7bebaad643c233e5057da7a0d42441c0789",
|
"matrix-js-sdk": "matrix-org/matrix-js-sdk#8e9a04cdec0f88fc876bbbf406db55b0677f005d",
|
||||||
"matrix-widget-api": "^1.8.2",
|
"matrix-widget-api": "^1.10.0",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"observable-hooks": "^4.2.3",
|
"observable-hooks": "^4.2.3",
|
||||||
"pako": "^2.0.4",
|
"pako": "^2.0.4",
|
||||||
|
|||||||
@@ -81,8 +81,8 @@
|
|||||||
"call_ended_heading": "Anruf beendet",
|
"call_ended_heading": "Anruf beendet",
|
||||||
"failed_heading": "Beitreten fehlgeschlagen",
|
"failed_heading": "Beitreten fehlgeschlagen",
|
||||||
"failed_text": "Anruf nicht gefunden oder Beitritt nicht erlaubt",
|
"failed_text": "Anruf nicht gefunden oder Beitritt nicht erlaubt",
|
||||||
"knock_reject_body": "Die Mitglieder des Raums haben Deine Beitrittsanfrage abgelehnt.",
|
"knock_reject_body": "Die Teilnahmeanfrage wurde abgelehnt.",
|
||||||
"knock_reject_heading": "Beitritt nicht erlaubt",
|
"knock_reject_heading": "Zugriff verweigert",
|
||||||
"reason": "Grund"
|
"reason": "Grund"
|
||||||
},
|
},
|
||||||
"hangup_button_label": "Anruf beenden",
|
"hangup_button_label": "Anruf beenden",
|
||||||
@@ -100,11 +100,11 @@
|
|||||||
"layout_grid_label": "Raster",
|
"layout_grid_label": "Raster",
|
||||||
"layout_spotlight_label": "Fokus",
|
"layout_spotlight_label": "Fokus",
|
||||||
"lobby": {
|
"lobby": {
|
||||||
"ask_to_join": "Beitritt anfragen",
|
"ask_to_join": "Teilnahmeanfrage senden",
|
||||||
"join_as_guest": "Als Gast beitreten",
|
"join_as_guest": "Als Gast beitreten",
|
||||||
"join_button": "Anruf beitreten",
|
"join_button": "Anruf beitreten",
|
||||||
"leave_button": "Zurück zu kürzlichen Anrufen",
|
"leave_button": "Zurück zu kürzlichen Anrufen",
|
||||||
"waiting_for_invite": "Anfrage gesendet"
|
"waiting_for_invite": "Anfrage gesendet! Warten auf Teilnahmefreigabe …"
|
||||||
},
|
},
|
||||||
"log_in": "Anmelden",
|
"log_in": "Anmelden",
|
||||||
"logging_in": "Anmelden …",
|
"logging_in": "Anmelden …",
|
||||||
|
|||||||
@@ -81,8 +81,8 @@
|
|||||||
"call_ended_heading": "Call ended",
|
"call_ended_heading": "Call ended",
|
||||||
"failed_heading": "Failed to join",
|
"failed_heading": "Failed to join",
|
||||||
"failed_text": "Call not found or is not accessible.",
|
"failed_text": "Call not found or is not accessible.",
|
||||||
"knock_reject_body": "The room members declined your request to join.",
|
"knock_reject_body": "Your request to join was declined.",
|
||||||
"knock_reject_heading": "Not allowed to join",
|
"knock_reject_heading": "Access denied",
|
||||||
"reason": "Reason"
|
"reason": "Reason"
|
||||||
},
|
},
|
||||||
"hangup_button_label": "End call",
|
"hangup_button_label": "End call",
|
||||||
@@ -100,11 +100,11 @@
|
|||||||
"layout_grid_label": "Grid",
|
"layout_grid_label": "Grid",
|
||||||
"layout_spotlight_label": "Spotlight",
|
"layout_spotlight_label": "Spotlight",
|
||||||
"lobby": {
|
"lobby": {
|
||||||
"ask_to_join": "Ask to join call",
|
"ask_to_join": "Request to join call",
|
||||||
"join_as_guest": "Join as guest",
|
"join_as_guest": "Join as guest",
|
||||||
"join_button": "Join call",
|
"join_button": "Join call",
|
||||||
"leave_button": "Back to recents",
|
"leave_button": "Back to recents",
|
||||||
"waiting_for_invite": "Request sent"
|
"waiting_for_invite": "Request sent! Waiting for permission to join…"
|
||||||
},
|
},
|
||||||
"log_in": "Log In",
|
"log_in": "Log In",
|
||||||
"logging_in": "Logging 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 { FC, useCallback } from "react";
|
||||||
import { Root, Track, Range, Thumb } from "@radix-ui/react-slider";
|
import { Root, Track, Range, Thumb } from "@radix-ui/react-slider";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import styles from "./Slider.module.css";
|
import styles from "./Slider.module.css";
|
||||||
|
|
||||||
@@ -66,7 +67,10 @@ export const Slider: FC<Props> = ({
|
|||||||
<Track className={styles.track}>
|
<Track className={styles.track}>
|
||||||
<Range className={styles.highlight} />
|
<Range className={styles.highlight} />
|
||||||
</Track>
|
</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>
|
</Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,6 +46,16 @@
|
|||||||
border-radius: var(--cpd-radius-pill-effect);
|
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 {
|
.verticalSeperator {
|
||||||
background-color: var(--cpd-color-gray-800);
|
background-color: var(--cpd-color-gray-800);
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
@@ -96,6 +96,30 @@ export interface ConfigOptions {
|
|||||||
* Note that this can additionally be disabled by the app's URL parameters.
|
* Note that this can additionally be disabled by the app's URL parameters.
|
||||||
*/
|
*/
|
||||||
app_prompt?: boolean;
|
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
|
// 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) {
|
if (skipLobby) {
|
||||||
// In preload mode without lobby we wait for a join action before entering
|
if (widget) {
|
||||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
if (preload) {
|
||||||
(async (): Promise<void> => {
|
// In preload mode without lobby we wait for a join action before entering
|
||||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
(async (): Promise<void> => {
|
||||||
widget!.api.transport.reply(ev.detail, {});
|
await defaultDeviceSetup(
|
||||||
})().catch((e) => {
|
ev.detail.data as unknown as JoinCallData,
|
||||||
logger.error("Error joining RTC session", e);
|
);
|
||||||
});
|
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||||
};
|
widget!.api.transport.reply(ev.detail, {});
|
||||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
})().catch((e) => {
|
||||||
return (): void => {
|
logger.error("Error joining RTC session", e);
|
||||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
});
|
||||||
};
|
};
|
||||||
} else if (widget && !preload && skipLobby) {
|
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||||
// No lobby and no preload: we enter the rtc session right away
|
return (): void => {
|
||||||
(async (): Promise<void> => {
|
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
};
|
||||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
} else {
|
||||||
})().catch((e) => {
|
// No lobby and no preload: we enter the rtc session right away
|
||||||
logger.error("Error joining RTC session", e);
|
(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]);
|
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||||
|
|
||||||
|
|||||||
@@ -180,48 +180,3 @@ Please see LICENSE in the repository root for full details.
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex-grow: 1;
|
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 handSoundMp3 from "../sound/raise_hand.mp3?url";
|
||||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||||
import { useSwitchCamera } from "./useSwitchCamera";
|
import { useSwitchCamera } from "./useSwitchCamera";
|
||||||
import {
|
import { soundEffectVolumeSetting, useSetting } from "../settings/settings";
|
||||||
soundEffectVolumeSetting,
|
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||||
showReactions,
|
|
||||||
useSetting,
|
|
||||||
} from "../settings/settings";
|
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -180,27 +177,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
connState,
|
connState,
|
||||||
onShareClick,
|
onShareClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [shouldShowReactions] = useSetting(showReactions);
|
|
||||||
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
||||||
const { supportsReactions, raisedHands, reactions } = useReactions();
|
const { supportsReactions, raisedHands } = useReactions();
|
||||||
const raisedHandCount = useMemo(
|
const raisedHandCount = useMemo(
|
||||||
() => Object.keys(raisedHands).length,
|
() => Object.keys(raisedHands).length,
|
||||||
[raisedHands],
|
[raisedHands],
|
||||||
);
|
);
|
||||||
const previousRaisedHandCount = useDeferredValue(raisedHandCount);
|
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();
|
useWakeLock();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -686,15 +670,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<source src={handSoundMp3} type="audio/mpeg" />
|
<source src={handSoundMp3} type="audio/mpeg" />
|
||||||
</audio>
|
</audio>
|
||||||
<ReactionsAudioRenderer />
|
<ReactionsAudioRenderer />
|
||||||
{reactionsIcons.map(({ sender, emoji, startX }) => (
|
<ReactionsOverlay />
|
||||||
<span
|
|
||||||
style={{ left: `${startX}vw` }}
|
|
||||||
className={styles.floatingReaction}
|
|
||||||
key={sender}
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{footer}
|
{footer}
|
||||||
{layout.type !== "pip" && (
|
{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.
|
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 { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
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 { useClientLegacy } from "../ClientContext";
|
||||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
import { ErrorView, FullScreenView, LoadingView } from "../FullScreenView";
|
||||||
import { RoomAuthView } from "./RoomAuthView";
|
import { RoomAuthView } from "./RoomAuthView";
|
||||||
import { GroupCallLoader } from "./GroupCallLoader";
|
|
||||||
import { GroupCallView } from "./GroupCallView";
|
import { GroupCallView } from "./GroupCallView";
|
||||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
@@ -21,13 +22,14 @@ import { HomePage } from "../home/HomePage";
|
|||||||
import { platform } from "../Platform";
|
import { platform } from "../Platform";
|
||||||
import { AppSelectionModal } from "./AppSelectionModal";
|
import { AppSelectionModal } from "./AppSelectionModal";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { GroupCallStatus } from "./useLoadGroupCall";
|
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
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";
|
import { Config } from "../config/Config";
|
||||||
|
import { Link } from "../button/Link";
|
||||||
|
|
||||||
export const RoomPage: FC = () => {
|
export const RoomPage: FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -53,6 +55,7 @@ export const RoomPage: FC = () => {
|
|||||||
useClientLegacy();
|
useClientLegacy();
|
||||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||||
|
|
||||||
|
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||||
const muteStates = useMuteStates();
|
const muteStates = useMuteStates();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,82 +85,112 @@ export const RoomPage: FC = () => {
|
|||||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||||
}, [optInAnalytics, setOptInAnalytics]);
|
}, [optInAnalytics, setOptInAnalytics]);
|
||||||
|
|
||||||
const groupCallView = useCallback(
|
const wasInWaitForInviteState = useRef<boolean>(false);
|
||||||
(groupCallState: GroupCallStatus): JSX.Element => {
|
|
||||||
switch (groupCallState.kind) {
|
useEffect(() => {
|
||||||
case "loaded":
|
if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) {
|
||||||
return (
|
logger.log("Play join sound 'Not yet implemented'");
|
||||||
<GroupCallView
|
}
|
||||||
client={client!}
|
}, [groupCallState.kind]);
|
||||||
rtcSession={groupCallState.rtcSession}
|
|
||||||
isPasswordlessUser={passwordlessUser}
|
const groupCallView = (): JSX.Element => {
|
||||||
confineToRoom={confineToRoom}
|
switch (groupCallState.kind) {
|
||||||
preload={preload}
|
case "loaded":
|
||||||
skipLobby={skipLobby}
|
return (
|
||||||
hideHeader={hideHeader}
|
<GroupCallView
|
||||||
muteStates={muteStates}
|
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":
|
return (
|
||||||
case "canKnock": {
|
<LobbyView
|
||||||
const knock =
|
client={client!}
|
||||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
matrixInfo={{
|
||||||
const label: string | JSX.Element =
|
userId: client!.getUserId() ?? "",
|
||||||
groupCallState.kind === "canKnock" ? (
|
displayName: userDisplayName ?? "",
|
||||||
t("lobby.ask_to_join")
|
avatarUrl: avatarUrl ?? "",
|
||||||
) : (
|
roomAlias: null,
|
||||||
<>
|
roomId: groupCallState.roomSummary.room_id,
|
||||||
{t("lobby.waiting_for_invite")}
|
roomName: groupCallState.roomSummary.name ?? "",
|
||||||
<CheckIcon />
|
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||||
</>
|
e2eeSystem: {
|
||||||
);
|
kind: groupCallState.roomSummary["im.nheko.summary.encryption"]
|
||||||
return (
|
? E2eeType.PER_PARTICIPANT
|
||||||
<LobbyView
|
: E2eeType.NONE,
|
||||||
client={client!}
|
},
|
||||||
matrixInfo={{
|
}}
|
||||||
userId: client!.getUserId() ?? "",
|
onEnter={(): void => knock?.()}
|
||||||
displayName: userDisplayName ?? "",
|
enterLabel={label}
|
||||||
avatarUrl: avatarUrl ?? "",
|
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||||
roomAlias: null,
|
confineToRoom={confineToRoom}
|
||||||
roomId: groupCallState.roomSummary.room_id,
|
hideHeader={hideHeader}
|
||||||
roomName: groupCallState.roomSummary.name ?? "",
|
participantCount={null}
|
||||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
muteStates={muteStates}
|
||||||
e2eeSystem: {
|
onShareClick={null}
|
||||||
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 <> </>;
|
|
||||||
}
|
}
|
||||||
},
|
case "loading":
|
||||||
[
|
return (
|
||||||
client,
|
<FullScreenView>
|
||||||
passwordlessUser,
|
<h1>{t("common.loading")}</h1>
|
||||||
confineToRoom,
|
</FullScreenView>
|
||||||
preload,
|
);
|
||||||
skipLobby,
|
case "failed":
|
||||||
hideHeader,
|
wasInWaitForInviteState.current = false;
|
||||||
muteStates,
|
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||||
t,
|
return (
|
||||||
userDisplayName,
|
<FullScreenView>
|
||||||
avatarUrl,
|
<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;
|
let content: ReactNode;
|
||||||
if (loading || isRegistering) {
|
if (loading || isRegistering) {
|
||||||
@@ -170,15 +203,7 @@ export const RoomPage: FC = () => {
|
|||||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||||
content = <HomePage />;
|
content = <HomePage />;
|
||||||
} else {
|
} else {
|
||||||
content = (
|
content = groupCallView();
|
||||||
<GroupCallLoader
|
|
||||||
client={client}
|
|
||||||
roomIdOrAlias={roomIdOrAlias}
|
|
||||||
viaServers={viaServers}
|
|
||||||
>
|
|
||||||
{groupCallView}
|
|
||||||
</GroupCallLoader>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ export class CallTerminatedMessage extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useLoadGroupCall = (
|
export const useLoadGroupCall = (
|
||||||
client: MatrixClient,
|
client: MatrixClient | undefined,
|
||||||
roomIdOrAlias: string,
|
roomIdOrAlias: string | null,
|
||||||
viaServers: string[],
|
viaServers: string[],
|
||||||
): GroupCallStatus => {
|
): GroupCallStatus => {
|
||||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||||
@@ -159,6 +159,9 @@ export const useLoadGroupCall = (
|
|||||||
?.getContent().reason;
|
?.getContent().reason;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!client || !roomIdOrAlias) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const getRoomByAlias = async (alias: string): Promise<Room> => {
|
const getRoomByAlias = async (alias: string): Promise<Room> => {
|
||||||
// We lowercase the localpart when we create the room, so we must lowercase
|
// 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
|
// 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
|
// right now we assume everything is a room-scoped call
|
||||||
const livekitAlias = rtcSession.room.roomId;
|
const livekitAlias = rtcSession.room.roomId;
|
||||||
|
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
||||||
const useDeviceSessionMemberEvents =
|
const useDeviceSessionMemberEvents =
|
||||||
Config.get().features?.feature_use_device_session_member_events;
|
features?.feature_use_device_session_member_events;
|
||||||
rtcSession.joinRoomSession(
|
rtcSession.joinRoomSession(
|
||||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
||||||
makeActiveFocus(),
|
makeActiveFocus(),
|
||||||
@@ -108,6 +109,11 @@ export async function enterRTCSession(
|
|||||||
...(useDeviceSessionMemberEvents !== undefined && {
|
...(useDeviceSessionMemberEvents !== undefined && {
|
||||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
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>(
|
export const soundEffectVolumeSetting = new Setting<number>(
|
||||||
"sound-effect-volume",
|
"sound-effect-volume",
|
||||||
1,
|
0.5,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
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);
|
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(
|
private readonly gridLayout: Observable<LayoutMedia> = combineLatest(
|
||||||
[this.grid, this.spotlight],
|
[this.grid, this.spotlight],
|
||||||
(grid, spotlight) => ({
|
(grid, spotlight) => ({
|
||||||
@@ -856,6 +835,24 @@ export class CallViewModel extends ViewModel {
|
|||||||
pip: pip ?? undefined,
|
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(
|
private readonly pipLayout: Observable<LayoutMedia> = this.spotlight.pipe(
|
||||||
map((spotlight) => ({ type: "pip", spotlight })),
|
map((spotlight) => ({ type: "pip", spotlight })),
|
||||||
);
|
);
|
||||||
@@ -871,16 +868,9 @@ export class CallViewModel extends ViewModel {
|
|||||||
switchMap((gridMode) => {
|
switchMap((gridMode) => {
|
||||||
switch (gridMode) {
|
switch (gridMode) {
|
||||||
case "grid":
|
case "grid":
|
||||||
return this.oneOnOne.pipe(
|
return this.oneOnOneLayout.pipe(
|
||||||
switchMap(
|
switchMap((oneOnOne) =>
|
||||||
(oneOnOne): Observable<LayoutMedia> =>
|
oneOnOne === null ? this.gridLayout : of(oneOnOne),
|
||||||
oneOnOne
|
|
||||||
? of({
|
|
||||||
type: "one-on-one",
|
|
||||||
local: oneOnOne.local,
|
|
||||||
remote: oneOnOne.remote,
|
|
||||||
})
|
|
||||||
: this.gridLayout,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case "spotlight":
|
case "spotlight":
|
||||||
@@ -895,20 +885,20 @@ export class CallViewModel extends ViewModel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
case "narrow":
|
case "narrow":
|
||||||
return this.oneOnOne.pipe(
|
return this.oneOnOneLayout.pipe(
|
||||||
switchMap((oneOnOne) =>
|
switchMap((oneOnOne) =>
|
||||||
oneOnOne
|
oneOnOne === null
|
||||||
? // The expanded spotlight layout makes for a better one-on-one
|
? combineLatest(
|
||||||
// experience in narrow windows
|
|
||||||
this.spotlightExpandedLayout
|
|
||||||
: combineLatest(
|
|
||||||
[this.grid, this.spotlight],
|
[this.grid, this.spotlight],
|
||||||
(grid, spotlight) =>
|
(grid, spotlight) =>
|
||||||
grid.length > smallMobileCallThreshold ||
|
grid.length > smallMobileCallThreshold ||
|
||||||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||||
? this.spotlightPortraitLayout
|
? this.spotlightPortraitLayout
|
||||||
: this.gridLayout,
|
: 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":
|
case "flat":
|
||||||
|
|||||||
@@ -195,11 +195,12 @@ export const ReactionsProvider = ({
|
|||||||
// Skip any event without a sender or event ID.
|
// Skip any event without a sender or event ID.
|
||||||
if (!sender || !reactionEventId) return;
|
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) {
|
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 content: ECallReactionEventContent = event.getContent();
|
||||||
|
|
||||||
const membershipEventId = content?.["m.relates_to"]?.event_id;
|
const membershipEventId = content?.["m.relates_to"]?.event_id;
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export async function initClient(
|
|||||||
localTimeoutMs: 5000,
|
localTimeoutMs: 5000,
|
||||||
useE2eForGroupCall: e2eEnabled,
|
useE2eForGroupCall: e2eEnabled,
|
||||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
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:
|
// 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