feat: Add option to start video call in DM (#2745)
* add option to start video all in DM * show speaker icon for dm's in call status name * show call view if call is active in room * add Atria call ringtone * update element call and widget api * add option to start voice/video call in dms * only show call button if user have permission * allow call widget to send call notification event * show incoming call dialog and play sound * fix call permission checks * allow option to start call in all rooms * send notification when starting call in non-voice rooms * hide header call button from voice rooms * prevent call join if call not supported and started by other party * update call menu style * show call not supported message on incoming call notification * improve the incoming call layout * video call with right click without opening menu * allow call widget to fetch media url * add webRTC missing error * improve call permission label --------- Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
This commit is contained in:
parent
02d1001583
commit
e5e0b96861
17 changed files with 632 additions and 41 deletions
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -44,7 +44,7 @@
|
|||
"linkify-react": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.13.0",
|
||||
"matrix-widget-api": "1.16.1",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
"ua-parser-js": "1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@element-hq/element-call-embedded": "0.19.1",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
|
|
@ -1837,9 +1837,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@element-hq/element-call-embedded": {
|
||||
"version": "0.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz",
|
||||
"integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==",
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.19.1.tgz",
|
||||
"integrity": "sha512-RDZY3P3LTx10ACaGhzkwh2+boNB3x54zHF/7v/cCyoQlAVfEYMhgMEb4CRTwJFwwYFe1r++6Higa0A0G5XxZ8Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
|
|
@ -12119,9 +12119,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/matrix-widget-api": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz",
|
||||
"integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz",
|
||||
"integrity": "sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@
|
|||
"linkify-react": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.13.0",
|
||||
"matrix-widget-api": "1.16.1",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
"ua-parser-js": "1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@element-hq/element-call-embedded": "0.19.1",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
|
|
|
|||
BIN
public/sound/call.ogg
Normal file
BIN
public/sound/call.ogg
Normal file
Binary file not shown.
|
|
@ -1,5 +1,32 @@
|
|||
import React, { ReactNode, useCallback, useRef } from 'react';
|
||||
/* eslint-disable jsx-a11y/media-has-caption */
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Dialog,
|
||||
Icon,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import {
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import {
|
||||
CallEmbedContextProvider,
|
||||
CallEmbedRefContextProvider,
|
||||
|
|
@ -7,11 +34,316 @@ import {
|
|||
useCallJoined,
|
||||
useCallThemeSync,
|
||||
useCallMemberSoundSync,
|
||||
useCallStart,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import CallSound from '../../../public/sound/call.ogg';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||
import { getStateEvent } from '../utils/room';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||
import { webRTCSupported } from '../utils/rtc';
|
||||
|
||||
type IncomingCallInfo = {
|
||||
room: Room;
|
||||
sender: string;
|
||||
senderTs: number;
|
||||
lifetime: number;
|
||||
intent?: string;
|
||||
notificationType: RTCNotificationType;
|
||||
refEventId: string;
|
||||
};
|
||||
type IncomingCallProps = {
|
||||
dm: boolean;
|
||||
info: IncomingCallInfo;
|
||||
onIgnore: () => void;
|
||||
onAnswer: (room: Room, video: boolean) => void;
|
||||
onReject: (room: Room, eventId: string) => void;
|
||||
};
|
||||
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
const canAnswer = livekitSupported && rtcSupported;
|
||||
const { room } = info;
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room, dm);
|
||||
const avatarUrl = roomAvatar
|
||||
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const session = useCallSession(room);
|
||||
useCallMembersChange(
|
||||
session,
|
||||
useCallback(() => {
|
||||
const members = MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription);
|
||||
if (members.length === 0) {
|
||||
onIgnore();
|
||||
}
|
||||
}, [room, session, onIgnore])
|
||||
);
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audioElement = audioRef.current;
|
||||
audioElement?.play();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (info.notificationType === 'ring') {
|
||||
playSound();
|
||||
}
|
||||
}, [playSound, info.notificationType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => onIgnore(),
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||
<Text size="T200" align="Center">
|
||||
{info.sender}
|
||||
</Text>
|
||||
<Box direction="Column" gap="500" alignItems="Center">
|
||||
<Box shrink="No">
|
||||
<Avatar size="500" className={CallAvatarAnimation}>
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
size="400"
|
||||
joinRule={room.getJoinRule()}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="H3" align="Center" truncate>
|
||||
{roomName}
|
||||
</Text>
|
||||
<Text size="T300">Incoming Call</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{!livekitSupported && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your homeserver does not support calling.
|
||||
</Text>
|
||||
)}
|
||||
{!webRTCSupported && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => onAnswer(room, info.intent === 'video')}
|
||||
before={
|
||||
<Icon
|
||||
size="200"
|
||||
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
||||
filled
|
||||
/>
|
||||
}
|
||||
disabled={!canAnswer}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Answer
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
{dm ? 'Reject' : 'Ignore'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
||||
<source src={CallSound} type="audio/ogg" />
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type IncomingCallListenerProps = {
|
||||
callEmbed?: CallEmbed;
|
||||
joined?: boolean;
|
||||
};
|
||||
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||
const startCall = useCallStart(dm);
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
|
||||
if (event.isEncrypted()) {
|
||||
if (!event.isBeingDecrypted()) {
|
||||
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
|
||||
}
|
||||
await event.getDecryptionPromise();
|
||||
}
|
||||
|
||||
if (
|
||||
!room ||
|
||||
event.getType() !== EventType.RTCNotification ||
|
||||
event.getSender() === mx.getSafeUserId() ||
|
||||
!data.liveEvent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = event.getSender();
|
||||
const content = event.getContent<IRTCNotificationContent>();
|
||||
const senderTs =
|
||||
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
|
||||
const lifetime = Math.min(content.lifetime, 120000);
|
||||
const notificationType = content.notification_type;
|
||||
const relation =
|
||||
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
||||
const refEventId = relation?.event_id;
|
||||
|
||||
const mention =
|
||||
content['m.mentions'].room || content['m.mentions'].user_ids?.includes(mx.getSafeUserId());
|
||||
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
|
||||
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
|
||||
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
|
||||
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||
|
||||
const hasCallPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
if (!hasCallPermission) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
sender,
|
||||
senderTs,
|
||||
lifetime,
|
||||
intent:
|
||||
'm.call.intent' in content && typeof content['m.call.intent'] === 'string'
|
||||
? content['m.call.intent']
|
||||
: undefined,
|
||||
notificationType,
|
||||
refEventId,
|
||||
};
|
||||
|
||||
setCallInfo(info);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||
};
|
||||
}, [mx, handleTimelineEvent]);
|
||||
|
||||
const handleIgnore = useCallback(() => {
|
||||
setCallInfo(undefined);
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback(
|
||||
(room: Room, eventId: string) => {
|
||||
mx.sendEvent(room.roomId, EventType.RTCDecline, {
|
||||
'm.relates_to': {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: eventId,
|
||||
},
|
||||
});
|
||||
setCallInfo(undefined);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
(room: Room, video: boolean) => {
|
||||
startCall(room, { microphone: true, video, sound: true });
|
||||
setCallInfo(undefined);
|
||||
navigateRoom(room.roomId);
|
||||
},
|
||||
[startCall, navigateRoom]
|
||||
);
|
||||
|
||||
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
|
||||
return null;
|
||||
}
|
||||
return !joined && callInfo ? (
|
||||
<IncomingCall
|
||||
dm={dm}
|
||||
info={callInfo}
|
||||
onIgnore={handleIgnore}
|
||||
onAnswer={handleAnswer}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
|
@ -47,7 +379,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
</CallEmbedRefContextProvider>
|
||||
<div
|
||||
data-call-embed-container
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Chip, Text } from 'folds';
|
||||
import { Chip, Icon, Icons, Text } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { RoomIcon } from '../../components/room-avatar';
|
||||
|
|
@ -38,7 +38,11 @@ export function CallRoomName({ room }: CallRoomNameProps) {
|
|||
variant="Background"
|
||||
radii="Pill"
|
||||
before={
|
||||
dm ? (
|
||||
<Icon size="200" src={Icons.VolumeHigh} filled />
|
||||
) : (
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
|
||||
)
|
||||
}
|
||||
onClick={() => navigateRoom(room.roomId)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,20 @@ import { CallMemberRenderer } from './CallMemberCard';
|
|||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
function LivekitServerMissingMessage() {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your homeserver does not support calling. But you can still join call started by others.
|
||||
Your homeserver does not support calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function WebRTCMissingError() {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,16 +35,22 @@ function LivekitServerMissingMessage() {
|
|||
function JoinMessage({
|
||||
hasParticipant,
|
||||
livekitSupported,
|
||||
rtcSupported,
|
||||
}: {
|
||||
hasParticipant?: boolean;
|
||||
livekitSupported?: boolean;
|
||||
rtcSupported?: boolean;
|
||||
}) {
|
||||
if (hasParticipant) return null;
|
||||
if (rtcSupported === false) {
|
||||
return <WebRTCMissingError />;
|
||||
}
|
||||
|
||||
if (livekitSupported === false) {
|
||||
return <LivekitServerMissingMessage />;
|
||||
}
|
||||
|
||||
if (hasParticipant) return null;
|
||||
|
||||
return (
|
||||
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||||
Voice chat’s empty — Be the first to hop in!
|
||||
|
|
@ -63,12 +78,16 @@ function CallPrescreen() {
|
|||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
|
||||
const hasPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(room, callSession);
|
||||
|
|
@ -77,7 +96,7 @@ function CallPrescreen() {
|
|||
const callEmbed = useCallEmbed();
|
||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||
|
||||
const canJoin = hasPermission && (livekitSupported || hasParticipant);
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported;
|
||||
|
||||
return (
|
||||
<Scroll variant="Surface" hideTrack>
|
||||
|
|
@ -100,7 +119,11 @@ function CallPrescreen() {
|
|||
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||
{!inOtherCall &&
|
||||
(hasPermission ? (
|
||||
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
|
||||
<JoinMessage
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
/>
|
||||
) : (
|
||||
<NoPermissionMessage />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
|||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -293,13 +294,13 @@ export function RoomNavItem({
|
|||
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
|
||||
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||
|
||||
const hasCallPermission = permissions.event(
|
||||
const hasCallPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
|
||||
// Do not join if missing permissions or no livekit support and call is not started by others
|
||||
if (!hasCallPermission || (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0)) {
|
||||
// Do not join if missing permissions or no livekit support or no webRTC support
|
||||
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +379,7 @@ export function RoomNavItem({
|
|||
aria-label={notificationMode}
|
||||
/>
|
||||
)}
|
||||
{room.isCallRoom() && callMembers.length > 0 && (
|
||||
{callMembers.length > 0 && (
|
||||
<Badge variant="Critical" fill="Solid" size="400">
|
||||
<Text as="span" size="L400" truncate>
|
||||
{callMembers.length} Live
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
|
|||
|
||||
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
|
||||
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
|
||||
const permissionGroups = usePermissionGroups(room.isCallRoom());
|
||||
const permissionGroups = usePermissionGroups();
|
||||
|
||||
const [powerEditor, setPowerEditor] = useState(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
|||
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||
import { PermissionGroup } from '../../common-settings/permissions';
|
||||
|
||||
export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
||||
export const usePermissionGroups = (): PermissionGroup[] => {
|
||||
const groups: PermissionGroup[] = useMemo(() => {
|
||||
const messagesGroup: PermissionGroup = {
|
||||
name: 'Messages',
|
||||
|
|
@ -54,7 +54,7 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
|||
state: true,
|
||||
key: StateEvent.GroupCallMemberPrefix,
|
||||
},
|
||||
name: 'Join Call',
|
||||
name: 'Start or Join Call',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -216,13 +216,13 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
|||
|
||||
return [
|
||||
messagesGroup,
|
||||
...(isCallRoom ? [callSettingsGroup] : []),
|
||||
callSettingsGroup,
|
||||
moderationGroup,
|
||||
roomOverviewGroup,
|
||||
roomSettingsGroup,
|
||||
otherSettingsGroup,
|
||||
];
|
||||
}, [isCallRoom]);
|
||||
}, []);
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,12 +18,18 @@ import { CallView } from '../call/CallView';
|
|||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { CallChatView } from './CallChatView';
|
||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(room, callSession);
|
||||
const callEmbed = useCallEmbed();
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
|
@ -43,7 +49,7 @@ export function Room() {
|
|||
)
|
||||
);
|
||||
|
||||
const callView = room.isCallRoom();
|
||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
Button,
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
|
|
@ -68,6 +69,9 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -253,6 +257,132 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||
);
|
||||
});
|
||||
|
||||
type CallMenuProps = {
|
||||
onVoiceCall: () => void;
|
||||
onVideoCall: () => void;
|
||||
requestClose: () => void;
|
||||
};
|
||||
const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
|
||||
({ requestClose, onVoiceCall, onVideoCall }, ref) => {
|
||||
const handleVoice = () => {
|
||||
onVoiceCall();
|
||||
requestClose();
|
||||
};
|
||||
const handleVideo = () => {
|
||||
onVideoCall();
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ padding: config.space.S200, minWidth: toRem(150) }}>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Start Call</Text>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.Phone} filled />}
|
||||
onClick={handleVoice}
|
||||
>
|
||||
<Text size="B300">Voice</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.VideoCamera} filled />}
|
||||
onClick={handleVideo}
|
||||
>
|
||||
<Text size="B300">Video</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function CallButton() {
|
||||
const room = useRoom();
|
||||
const direct = useIsDirectRoom();
|
||||
|
||||
const callEmbed = useCallEmbed();
|
||||
const startCall = useCallStart(direct);
|
||||
const callStarted = callEmbed && callEmbed.roomId === room.roomId;
|
||||
const inAnotherCall = callEmbed && !callStarted;
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
{inAnotherCall ? (
|
||||
<Text size="L400">Already in another call — End the current call to join!</Text>
|
||||
) : (
|
||||
<Text>Call</Text>
|
||||
)}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleOpenMenu}
|
||||
onContextMenu={(evt) => {
|
||||
evt.preventDefault();
|
||||
startCall(room, {
|
||||
microphone: true,
|
||||
video: true,
|
||||
sound: true,
|
||||
});
|
||||
}}
|
||||
disabled={inAnotherCall || callStarted}
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
<Icon size="400" src={Icons.VideoCamera} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<CallMenu
|
||||
onVideoCall={() => startCall(room, { microphone: true, video: true, sound: true })}
|
||||
onVoiceCall={() => startCall(room, { microphone: true, video: false, sound: true })}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -260,6 +390,17 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
const screenSize = useScreenSizeContext();
|
||||
const room = useRoom();
|
||||
const space = useSpaceOptionally();
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
||||
const hasCallPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const direct = useIsDirectRoom();
|
||||
|
|
@ -453,7 +594,9 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
||||
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && (
|
||||
<CallButton />
|
||||
)}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const createCallEmbed = (
|
|||
const ongoing =
|
||||
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
|
||||
|
||||
const intent = CallEmbed.getIntent(dm, ongoing);
|
||||
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
|
||||
const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
|
||||
|
||||
|
|
@ -101,6 +101,7 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
|
|||
|
||||
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
||||
};
|
||||
|
||||
export const useCallMemberSoundSync = (embed: CallEmbed) => {
|
||||
|
|
|
|||
|
|
@ -47,12 +47,36 @@ export class CallEmbed {
|
|||
|
||||
private readonly disposables: Array<() => void> = [];
|
||||
|
||||
static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent {
|
||||
if (ongoing) {
|
||||
return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting;
|
||||
static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
|
||||
if (dm && ongoing) {
|
||||
return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice;
|
||||
}
|
||||
if (dm) {
|
||||
return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice;
|
||||
}
|
||||
|
||||
return dm ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCall;
|
||||
if (ongoing) {
|
||||
return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingVoice;
|
||||
}
|
||||
return video ? ElementCallIntent.StartCall : ElementCallIntent.StartCallVoice;
|
||||
}
|
||||
|
||||
static dmCall(intent: ElementCallIntent): boolean {
|
||||
return (
|
||||
intent === ElementCallIntent.JoinExistingDM ||
|
||||
intent === ElementCallIntent.JoinExistingDMVoice ||
|
||||
intent === ElementCallIntent.StartCallDM ||
|
||||
intent === ElementCallIntent.StartCallDMVoice
|
||||
);
|
||||
}
|
||||
|
||||
static startingCall(intent: ElementCallIntent): boolean {
|
||||
return (
|
||||
intent === ElementCallIntent.StartCallDM ||
|
||||
intent === ElementCallIntent.StartCallDMVoice ||
|
||||
intent === ElementCallIntent.StartCall ||
|
||||
intent === ElementCallIntent.StartCallVoice
|
||||
);
|
||||
}
|
||||
|
||||
static getWidget(
|
||||
|
|
@ -81,8 +105,13 @@ export class CallEmbed {
|
|||
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
|
||||
lang: 'en-EN',
|
||||
theme: themeKind,
|
||||
header: 'none',
|
||||
});
|
||||
|
||||
if (!room.isCallRoom() && CallEmbed.startingCall(intent)) {
|
||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||
}
|
||||
|
||||
const widgetUrl = new URL(
|
||||
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
|
||||
window.location.origin
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export enum ElementCallIntent {
|
||||
StartCall = 'start_call',
|
||||
JoinExisting = 'join_existing',
|
||||
StartCallVoice = 'start_call_voice',
|
||||
JoinExistingVoice = 'join_existing_voice',
|
||||
StartCallDM = 'start_call_dm',
|
||||
JoinExistingDM = 'join_existing_dm',
|
||||
StartCallDMVoice = 'start_call_dm_voice',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export function getCallCapabilities(
|
|||
|
||||
capabilities.add(MatrixCapabilities.Screenshots);
|
||||
capabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||
capabilities.add(MatrixCapabilities.MSC4039UploadFile);
|
||||
capabilities.add(MatrixCapabilities.MSC4039DownloadFile);
|
||||
capabilities.add(MatrixCapabilities.MSC3846TurnServers);
|
||||
capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
|
||||
capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||
|
|
@ -78,19 +80,13 @@ export function getCallCapabilities(
|
|||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
|
||||
);
|
||||
|
||||
capabilities.add(
|
||||
WidgetEventCapability.forRoomEvent(
|
||||
EventDirection.Receive,
|
||||
'org.matrix.msc4075.rtc.notification'
|
||||
).raw
|
||||
);
|
||||
|
||||
[
|
||||
'io.element.call.encryption_keys',
|
||||
'org.matrix.rageshake_request',
|
||||
EventType.Reaction,
|
||||
EventType.RoomRedaction,
|
||||
'io.element.call.reaction',
|
||||
'org.matrix.msc4075.rtc.notification',
|
||||
'org.matrix.msc4310.rtc.decline',
|
||||
].forEach((type) => {
|
||||
capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, type).raw);
|
||||
|
|
|
|||
47
src/app/styles/Animations.css.ts
Normal file
47
src/app/styles/Animations.css.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, toRem } from 'folds';
|
||||
|
||||
const wobble = keyframes({
|
||||
'0%': {
|
||||
transform: 'translateX(0) rotateZ(0deg)',
|
||||
},
|
||||
'20%': {
|
||||
transform: `translateX(-${toRem(4)}) rotateZ(-4deg)`,
|
||||
},
|
||||
'40%': {
|
||||
transform: `translateX(${toRem(4)}) rotateZ(4deg)`,
|
||||
},
|
||||
'60%': {
|
||||
transform: `translateX(-${toRem(3)}) rotateZ(-3deg)`,
|
||||
},
|
||||
'80%': {
|
||||
transform: `translateX(${toRem(3)}) rotateZ(3deg)`,
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(0) rotateZ(0deg)',
|
||||
},
|
||||
});
|
||||
|
||||
const glowPulse = keyframes({
|
||||
'0%': {
|
||||
boxShadow: `0 0 0 ${toRem(0)} ${color.Success.ContainerActive}`,
|
||||
},
|
||||
'100%': {
|
||||
boxShadow: `0 0 0 ${toRem(8)} ${color.Success.ContainerActive}`,
|
||||
},
|
||||
});
|
||||
|
||||
export const WobbleAnimation = style({
|
||||
animation: `${wobble} 2000ms ease-in-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
|
||||
export const GlowAnimation = style({
|
||||
animation: `${glowPulse} 2000ms ease-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
|
||||
export const CallAvatarAnimation = style({
|
||||
animation: `${wobble} 2000ms ease-in-out, ${glowPulse} 2000ms ease-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
4
src/app/utils/rtc.ts
Normal file
4
src/app/utils/rtc.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const webRTCSupported = () =>
|
||||
['RTCPeerConnection', 'webkitRTCPeerConnection', 'mozRTCPeerConnection', 'RTCIceGatherer'].some(
|
||||
(item) => item in window
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue