diff --git a/package-lock.json b/package-lock.json index b7281a0..97d3cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8e7b37b..a6385ba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sound/call.ogg b/public/sound/call.ogg new file mode 100644 index 0000000..173f73d Binary files /dev/null and b/public/sound/call.ogg differ diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index a78c210..650cf05 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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(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 ( + <> + }> + + onIgnore(), + clickOutsideDeactivates: false, + escapeDeactivates: false, + }} + > + + + + {info.sender} + + + + + ( + + )} + /> + + + + + {roomName} + + Incoming Call + + + {!livekitSupported && ( + + Your homeserver does not support calling. + + )} + {!webRTCSupported && ( + + Your browser does not support WebRTC, which is required for calling. + + )} + + + + + + + + + + + + ); +} + +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(); + 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(); + 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 ? ( + + ) : null; +} function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); @@ -47,7 +379,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { return ( {callEmbed && } - {children} + + + {children} +
+ dm ? ( + + ) : ( + + ) } onClick={() => navigateRoom(room.roomId)} > diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 7c7bec6..92cb4f9 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -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 ( - Your homeserver does not support calling. But you can still join call started by others. + Your homeserver does not support calling. + + ); +} + +function WebRTCMissingError() { + return ( + + Your browser does not support WebRTC, which is required for calling. ); } @@ -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 ; + } if (livekitSupported === false) { return ; } + if (hasParticipant) return null; + return ( 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 ( @@ -100,7 +119,11 @@ function CallPrescreen() { {!inOtherCall && (hasPermission ? ( - + ) : ( ))} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 0f900f5..77e4159 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -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 && ( {callMembers.length} Live diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index fe6b098..7572a71 100644 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ b/src/app/features/room-settings/permissions/Permissions.tsx @@ -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); diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts index d4f5f56..d0500b8 100644 --- a/src/app/features/room-settings/permissions/usePermissionItems.ts +++ b/src/app/features/room-settings/permissions/usePermissionItems.ts @@ -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; }; diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 5e5d7d7..b3e8a4e 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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 ( diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index a19058d..8089b5e 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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(({ room, requestClose ); }); +type CallMenuProps = { + onVoiceCall: () => void; + onVideoCall: () => void; + requestClose: () => void; +}; +const CallMenu = forwardRef( + ({ requestClose, onVoiceCall, onVideoCall }, ref) => { + const handleVoice = () => { + onVoiceCall(); + requestClose(); + }; + const handleVideo = () => { + onVideoCall(); + requestClose(); + }; + + return ( + + + Start Call + + + + + + + ); + } +); + +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(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + <> + + {inAnotherCall ? ( + Already in another call — End the current call to join! + ) : ( + Call + )} + + } + > + {(triggerRef) => ( + { + evt.preventDefault(); + startCall(room, { + microphone: true, + video: true, + sound: true, + }); + }} + disabled={inAnotherCall || callStarted} + aria-pressed={!!menuAnchor} + > + + + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + startCall(room, { microphone: true, video: true, sound: true })} + onVoiceCall={() => startCall(room, { microphone: true, video: false, sound: true })} + requestClose={() => setMenuAnchor(undefined)} + /> + + } + /> + + ); +} + 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(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); const direct = useIsDirectRoom(); @@ -453,7 +594,9 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } /> - + {!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && ( + + )} {screenSize === ScreenSize.Desktop && ( 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) => { diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 8704667..f79b64b 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -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 diff --git a/src/app/plugins/call/types.ts b/src/app/plugins/call/types.ts index 4f4fc38..89a9e61 100644 --- a/src/app/plugins/call/types.ts +++ b/src/app/plugins/call/types.ts @@ -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', diff --git a/src/app/plugins/call/utils.ts b/src/app/plugins/call/utils.ts index 0ea72b3..58d6d96 100644 --- a/src/app/plugins/call/utils.ts +++ b/src/app/plugins/call/utils.ts @@ -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); diff --git a/src/app/styles/Animations.css.ts b/src/app/styles/Animations.css.ts new file mode 100644 index 0000000..c6bdc56 --- /dev/null +++ b/src/app/styles/Animations.css.ts @@ -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', +}); diff --git a/src/app/utils/rtc.ts b/src/app/utils/rtc.ts new file mode 100644 index 0000000..6a40504 --- /dev/null +++ b/src/app/utils/rtc.ts @@ -0,0 +1,4 @@ +export const webRTCSupported = () => + ['RTCPeerConnection', 'webkitRTCPeerConnection', 'mozRTCPeerConnection', 'RTCIceGatherer'].some( + (item) => item in window + );