diff --git a/.changeset/a_dm_and_room_calls.md b/.changeset/a_dm_and_room_calls.md new file mode 100644 index 000000000..0d8267210 --- /dev/null +++ b/.changeset/a_dm_and_room_calls.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Added ability to start calls in DMs and rooms. DM calls will trigger a notification popup & ringtone (for other sable users/compatible clients, probably). diff --git a/public/sound/ringtone.webm b/public/sound/ringtone.webm new file mode 100644 index 000000000..7c10b32aa Binary files /dev/null and b/public/sound/ringtone.webm differ diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index ba29f3b78..a8e9e7b6a 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -1,5 +1,6 @@ import { ReactNode, useCallback, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; +import { useAutoJoinCall } from '$hooks/useAutoJoinCall'; import { CallEmbedContextProvider, CallEmbedRefContextProvider, @@ -7,11 +8,12 @@ import { useCallJoined, useCallThemeSync, useCallMemberSoundSync, -} 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'; +} 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 { IncomingCallModal } from './IncomingCallModal'; function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); @@ -31,6 +33,12 @@ function CallUtils({ embed }: { embed: CallEmbed }) { type CallEmbedProviderProps = { children?: ReactNode; }; + +function AutoJoinManager() { + useAutoJoinCall(); + return null; +} + export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const callEmbed = useAtomValue(callEmbedAtom); const callEmbedRef = useRef(null); @@ -46,17 +54,20 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { return ( + {callEmbed && } - {children} + + + {children} + +
diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx new file mode 100644 index 000000000..da17db4f3 --- /dev/null +++ b/src/app/components/IncomingCallModal.tsx @@ -0,0 +1,143 @@ +import { + Box, + Dialog, + Header, + IconButton, + Icon, + Icons, + Text, + Button, + Avatar, + config, + Overlay, + OverlayCenter, + OverlayBackdrop, +} from 'folds'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRoomName } from '$hooks/useRoomMeta'; +import { getRoomAvatarUrl } from '$utils/room'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import FocusTrap from 'focus-trap-react'; +import { stopPropagation } from '$utils/keyboard'; +import { useAtom, useSetAtom } from 'jotai'; +import { + autoJoinCallIntentAtom, + incomingCallRoomIdAtom, + mutedCallRoomIdAtom, +} from '$state/callEmbed'; +import { RoomAvatar } from './room-avatar'; + +type IncomingCallInternalProps = { + room: any; + onClose: () => void; +}; + +export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProps) { + const mx = useMatrixClient(); + const roomName = useRoomName(room); + const { navigateRoom } = useRoomNavigate(); + const avatarUrl = getRoomAvatarUrl(mx, room, 96); + const setAutoJoinIntent = useSetAtom(autoJoinCallIntentAtom); + const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + + const handleAnswer = () => { + setMutedRoomId(room.roomId); + setAutoJoinIntent(room.roomId); + onClose(); + navigateRoom(room.roomId); + }; + + const handleDecline = async () => { + setMutedRoomId(room.roomId); + onClose(); + }; + + return ( + +
+ + Incoming Call + + + + +
+ + + + } + /> + + + + + {roomName} + + + Incoming voice chat request + + + + + + + + +
+ ); +} + +export function IncomingCallModal() { + const [ringingRoomId, setRingingRoomId] = useAtom(incomingCallRoomIdAtom); + const mx = useMatrixClient(); + const room = ringingRoomId ? mx.getRoom(ringingRoomId) : null; + + if (!ringingRoomId || !room) return null; + + const close = () => setRingingRoomId(null); + + return ( + }> + + +
+ +
+
+
+
+ ); +} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index ff59e7111..a68803375 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; import { EventType } from '$types/matrix-sdk'; import { useCallEmbed, useCallEmbedPlacementSync, useCallJoined } from '$hooks/useCallEmbed'; @@ -39,13 +39,20 @@ function AlreadyInCallMessage() { ); } -export function CallView() { +interface CallViewProps { + resizable?: boolean; +} + +export function CallView({ resizable }: CallViewProps) { const mx = useMatrixClient(); const room = useRoom(); const callViewRef = useRef(null); useCallEmbedPlacementSync(callViewRef); + const [height, setHeight] = useState(380); + const isResizing = useRef(false); + const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -62,13 +69,55 @@ export function CallView() { const currentJoined = callEmbed?.roomId === room.roomId && callJoined; + const [isDragging, setIsDragging] = useState(false); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isResizing.current || !callViewRef.current) return; + const { top } = callViewRef.current.getBoundingClientRect(); + setHeight(Math.max(150, Math.min(e.clientY - top, window.innerHeight * 0.8))); + }, []); + + const stopResizing = useCallback(() => { + isResizing.current = false; + setIsDragging(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', stopResizing); + document.body.style.userSelect = 'auto'; + }, [handleMouseMove]); + + const startResizing = useCallback(() => { + isResizing.current = true; + setIsDragging(true); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', stopResizing); + document.body.style.userSelect = 'none'; + }, [handleMouseMove, stopResizing]); + return ( + {isDragging && ( +
+ )} {!currentJoined && ( @@ -100,6 +149,40 @@ export function CallView() { )} + {resizable && ( + + )} ); } diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 474522cdf..70e63f3de 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -315,7 +315,7 @@ export function RoomNavItem({ }; const handleNavItemClick: MouseEventHandler = (evt) => { - if (room.isCallRoom()) { + if (room.isCallRoom() || (direct && callMembers.length > 0)) { if (!isMobile) { if (!isActiveCall && !callEmbed) { startCall( @@ -440,11 +440,14 @@ export function RoomNavItem({ aria-label={notificationMode} /> )} - {room.isCallRoom() && callMembers.length > 0 && !optionsVisible && ( + {(room.isCallRoom() || direct) && callMembers.length > 0 && !optionsVisible && ( - - {callMembers.length} Live - + + + + {direct ? 'Calling' : `${callMembers.length} Live`} + + )} @@ -452,7 +455,7 @@ export function RoomNavItem({ {optionsVisible && ( - {room.isCallRoom() && ( + {(room.isCallRoom() || (direct && callMembers.length > 0)) && ( { + startCall(room); + try { + const now = Date.now(); + // TODO not use as any one day someday i swear + await mx.sendEvent(room.roomId, 'org.matrix.msc4075.rtc.notification' as any, { + notification_type: 'ring', + sender_ts: now, + lifetime: 30000, + 'm.mentions': { + room: true, + }, + application: 'm.call', + call_id: room.roomId, + 'm.text': [ + { body: `Call started by ${mx.getUser(mx.getSafeUserId())?.displayName || 'User'} 🎶` }, + ], + }); + } catch { + /* skill issue block */ + } + }; + + return ( + + Start Voice Call + + } + > + {(triggerRef) => ( + + + + )} + + ); +} diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 15f64e936..ab6cc00c1 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -26,6 +26,10 @@ import { RoomSettingsPage } from '$state/roomSettings'; import { GlobalModalManager } from '$components/message/modals/GlobalModalManager'; import { useDelayedEventsSupport } from '$hooks/useDelayedEventsSupport'; import { delayedEventsSupportedAtom } from '$state/scheduledMessages'; +import { useCallMembers, useCallSession } from '$hooks/useCall'; +import { callEmbedAtom } from '$state/callEmbed'; +import { useCallJoined } from '$hooks/useCallEmbed'; +import { CallView } from '$features/call/CallView'; import { useRoom } from '$hooks/useRoom'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { RoomInput } from './RoomInput'; @@ -127,6 +131,12 @@ export function RoomView({ eventId }: { eventId?: string }) { } }, [screenSize, openSettings, room.roomId, space?.roomId]); + const callSession = useCallSession(room); + const callMembers = useCallMembers(room, callSession); + const callEmbed = useAtomValue(callEmbedAtom); + const isJoinedInThisRoom = useCallJoined(callEmbed) && callEmbed?.roomId === room.roomId; + const showCallView = callMembers.length > 0 || isJoinedInThisRoom; + return ( {(onBack) => ( @@ -140,6 +150,11 @@ export function RoomView({ eventId }: { eventId?: string }) { > + {showCallView && ( + + + + )} )} + {canUseCalls && } { + if (selectedRoomId && autoJoinIntent && selectedRoomId === autoJoinIntent) { + const room = mx.getRoom(selectedRoomId); + + if (room) { + startCall(room); + setAutoJoinIntent(null); + } + } + }, [selectedRoomId, autoJoinIntent, startCall, setAutoJoinIntent, mx]); +} diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts new file mode 100644 index 000000000..cd657e477 --- /dev/null +++ b/src/app/hooks/useCallSignaling.ts @@ -0,0 +1,179 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { RoomStateEvent } from 'matrix-js-sdk'; +import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; +import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager'; +import { useSetAtom, useAtomValue } from 'jotai'; +import { mDirectAtom } from '$state/mDirectList'; +import { incomingCallRoomIdAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; +import RingtoneSound from '$public/sound/ringtone.webm'; +import { useMatrixClient } from './useMatrixClient'; + +type CallPhase = 'IDLE' | 'RINGING_OUT' | 'RINGING_IN' | 'ACTIVE' | 'ENDED'; + +interface SignalState { + incoming: string | null; + outgoing: string | null; +} + +export function useCallSignaling() { + const mx = useMatrixClient(); + const setIncomingCall = useSetAtom(incomingCallRoomIdAtom); + const mDirects = useAtomValue(mDirectAtom); + + const incomingAudioRef = useRef(null); + const outgoingAudioRef = useRef(null); + const ringingRoomIdRef = useRef(null); + const outgoingStartRef = useRef(null); + const callPhaseRef = useRef>({}); + + const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); + const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + + useEffect(() => { + const inc = new Audio(RingtoneSound); + inc.loop = true; + incomingAudioRef.current = inc; + + const out = new Audio(RingtoneSound); + out.loop = true; + outgoingAudioRef.current = out; + + return () => { + inc.pause(); + out.pause(); + }; + }, []); + + const stopRinging = useCallback(() => { + incomingAudioRef.current?.pause(); + outgoingAudioRef.current?.pause(); + if (incomingAudioRef.current) incomingAudioRef.current.currentTime = 0; + if (outgoingAudioRef.current) outgoingAudioRef.current.currentTime = 0; + + ringingRoomIdRef.current = null; + setIncomingCall(null); + }, [setIncomingCall]); + + const playOutgoingRinging = useCallback((roomId: string) => { + if (outgoingAudioRef.current && ringingRoomIdRef.current !== roomId) { + outgoingAudioRef.current.play().catch(() => {}); + ringingRoomIdRef.current = roomId; + } + }, []); + + const playRinging = useCallback( + (roomId: string) => { + if (incomingAudioRef.current && ringingRoomIdRef.current !== roomId) { + incomingAudioRef.current.play().catch(() => {}); + ringingRoomIdRef.current = roomId; + setIncomingCall(roomId); + } + }, + [setIncomingCall] + ); + + useEffect(() => { + if (!mx || !mx.matrixRTC) return undefined; + + const checkDMsForActiveCalls = () => { + const myUserId = mx.getUserId(); + const now = Date.now(); + + const signal = Array.from(mDirects).reduce( + (acc, roomId) => { + if (acc.incoming || mutedRoomId === roomId) return acc; + + const room = mx.getRoom(roomId); + if (!room) return acc; + + const session = mx.matrixRTC.getRoomSession(room); + const memberships = MatrixRTCSession.sessionMembershipsForRoom( + room, + session.sessionDescription + ); + + const remoteMembers = memberships.filter((m: any) => (m.userId || m.sender) !== myUserId); + const isSelfInCall = memberships.some((m: any) => (m.userId || m.sender) === myUserId); + const currentPhase = callPhaseRef.current[roomId] || 'IDLE'; + + // no one here + if (!isSelfInCall && remoteMembers.length === 0) { + callPhaseRef.current[roomId] = 'IDLE'; + return acc; + } + + // being called + if (remoteMembers.length > 0 && !isSelfInCall) { + callPhaseRef.current[roomId] = 'RINGING_IN'; + return { ...acc, incoming: roomId }; + } + + // multiple people no ringtone + if (isSelfInCall && remoteMembers.length > 0) { + callPhaseRef.current[roomId] = 'ACTIVE'; + return acc; + } + + // alone in call + if (isSelfInCall && remoteMembers.length === 0) { + // Check if post call + if (currentPhase === 'ACTIVE' || currentPhase === 'ENDED') { + callPhaseRef.current[roomId] = 'ENDED'; + return acc; + } + + // Check if new call + if (currentPhase === 'IDLE' || currentPhase === 'RINGING_OUT') { + if (!outgoingStartRef.current) outgoingStartRef.current = now; + + if (now - outgoingStartRef.current < 30000) { + callPhaseRef.current[roomId] = 'RINGING_OUT'; + return { ...acc, outgoing: roomId }; + } + + callPhaseRef.current[roomId] = 'ENDED'; + } + } + + return acc; + }, + { incoming: null, outgoing: null } + ); + + if (signal.incoming) { + playRinging(signal.incoming); + } else if (signal.outgoing) { + playOutgoingRinging(signal.outgoing); + } else { + stopRinging(); + if (!signal.outgoing) outgoingStartRef.current = null; + } + }; + + const interval = setInterval(checkDMsForActiveCalls, 1000); + + const handleUpdate = () => checkDMsForActiveCalls(); + + const handleSessionEnded = (roomId: string) => { + if (mutedRoomId === roomId) setMutedRoomId(null); + callPhaseRef.current[roomId] = 'IDLE'; + checkDMsForActiveCalls(); + }; + + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); + mx.on(RoomStateEvent.Events, handleUpdate); + + checkDMsForActiveCalls(); + + return () => { + clearInterval(interval); + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); + mx.off(RoomStateEvent.Events, handleUpdate); + stopRinging(); + }; + }, [mx, mDirects, playRinging, stopRinging, mutedRoomId, setMutedRoomId, playOutgoingRinging]); + + return null; +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 9bf2162d1..9ece7f8b0 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -39,6 +39,7 @@ import { import { mobileOrTablet } from '$utils/user-agent'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; +import { useCallSignaling } from '$hooks/useCallSignaling'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -592,6 +593,7 @@ function PresenceFeature() { } export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + useCallSignaling(); return ( <> diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index a9f893009..1452fb971 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -18,3 +18,7 @@ export const callEmbedAtom = atom(false); + +export const incomingCallRoomIdAtom = atom(null); +export const autoJoinCallIntentAtom = atom(null); +export const mutedCallRoomIdAtom = atom(null); diff --git a/src/sw.ts b/src/sw.ts index a7fd738ec..85d4e6a17 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -358,6 +358,8 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { scope, }); + const isCall = data?.isCall === true; + // Build a canonical deep-link URL. // // Room messages: /to/:user_id/:room_id/:event_id? @@ -373,9 +375,10 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { if (pushUserId) u.searchParams.set('uid', pushUserId); targetUrl = u.href; } else if (pushUserId && pushRoomId) { + const callParam = isCall ? '?joinCall=true' : ''; const segments = pushEventId - ? `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${encodeURIComponent(pushEventId)}/` - : `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/`; + ? `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${encodeURIComponent(pushEventId)}/${callParam}` + : `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${callParam}`; targetUrl = new URL(segments, scope).href; } else { // Fallback: no room ID or no user ID in payload. @@ -410,6 +413,7 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { roomId: pushRoomId, eventId: pushEventId, isInvite, + isCall, }); // eslint-disable-next-line no-await-in-loop await wc.focus(); diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index 25c849ac4..1152d3d44 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -49,6 +49,29 @@ export const createPushNotifications = ( await self.registration.showNotification(title, notifOptions as NotificationOptions); }; + const handleCallNotification = async (pushData: any) => { + const content = pushData?.content; + if (content?.notification_type !== 'ring') return; + + const senderDisplayName = pushData?.sender_display_name; + const roomName = pushData?.room_name; + const title = 'Incoming Call'; + const body = senderDisplayName + ? `${senderDisplayName} is calling you ${roomName ? `in ${roomName}` : ''}` + : 'Incoming voice chat'; + + const data = { + type: pushData?.type, + room_id: pushData?.room_id, + user_id: pushData?.user_id, + timestamp: Date.now(), + isCall: true, + ...pushData.data, + }; + + await showNotificationWithData(title, body, data, resolveSilent(), pushData?.room_avatar_url); + }; + const handleRoomMessageNotification = async (pushData: any) => { const data = { type: pushData?.type, @@ -158,6 +181,10 @@ export const createPushNotifications = ( if (!(pushData?.content?.membership === 'invite')) break; await handleInvitationNotification(pushData); break; + case 'org.matrix.msc4075.call.notify': + case 'org.matrix.msc4075.rtc.notification': + await handleCallNotification(pushData); + break; default: // no voip support in app anyway break;