From c6d59a3852192c310cc696cb59ab1f41640b48b0 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sat, 18 Apr 2026 23:31:42 +0300 Subject: [PATCH] dm calls mvp: phase 1: outgoing DM voice call via element-call voiceOnly intent --- public/locales/en.json | 6 ++ public/locales/ru.json | 6 ++ src/app/components/CallEmbedProvider.tsx | 9 ++- src/app/features/call-status/CallControl.tsx | 28 ++++++---- src/app/features/room/RoomViewHeader.tsx | 59 ++++++++++++++++++++ src/app/hooks/useCallEmbed.ts | 17 +++--- src/app/hooks/useDmCallStart.ts | 24 ++++++++ src/app/pages/CallStatusRenderer.tsx | 7 ++- src/app/plugins/call/CallEmbed.ts | 18 ++++-- 9 files changed, 148 insertions(+), 26 deletions(-) create mode 100644 src/app/hooks/useDmCallStart.ts diff --git a/public/locales/en.json b/public/locales/en.json index 0f328455..abe1c7f6 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -382,6 +382,12 @@ "rate_limited": "Server rate-limited your request for {{minutes}} minutes!", "create": "Create" }, + "Call": { + "start": "Start call", + "join": "Join call", + "unavailable": "Calls are unavailable", + "busy_other_room": "You are already in a call" + }, "Room": { "new_messages": "New Messages", "jump_to_unread": "Jump to Unread", diff --git a/public/locales/ru.json b/public/locales/ru.json index b84b490e..ad7f1f9a 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -382,6 +382,12 @@ "rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!", "create": "Создать" }, + "Call": { + "start": "Позвонить", + "join": "Присоединиться", + "unavailable": "Звонки недоступны", + "busy_other_room": "Вы уже в другом звонке" + }, "Room": { "new_messages": "Новые сообщения", "jump_to_unread": "К непрочитанным", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index b50b1f50..51a5033d 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -1,6 +1,5 @@ import React, { ReactNode, useCallback, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; -import { config } from 'folds'; import { CallEmbedContextProvider, CallEmbedRefContextProvider, @@ -43,7 +42,12 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const chatOnlyView = chat && screenSize !== ScreenSize.Desktop; - const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView; + const callVisible = + callEmbed && + !callEmbed.voiceOnly && + selectedRoom === callEmbed.roomId && + joined && + !chatOnlyView; return ( @@ -53,6 +57,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { data-call-embed-container style={{ visibility: callVisible ? undefined : 'hidden', + pointerEvents: callVisible ? undefined : 'none', position: 'fixed', top: 0, left: 0, diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 6416fda5..6ffd936e 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -185,18 +185,22 @@ export function CallControl({ onToggle={() => callEmbed.control.toggleSound()} disabled={!callJoined} /> - {!compact && } - callEmbed.control.toggleVideo()} - disabled={!callJoined} - /> - {!compact && ( - callEmbed.control.toggleScreenshare()} - disabled={!callJoined} - /> + {!callEmbed.voiceOnly && ( + <> + {!compact && } + callEmbed.control.toggleVideo()} + disabled={!callJoined} + /> + {!compact && ( + callEmbed.control.toggleScreenshare()} + disabled={!callJoined} + /> + )} + )} diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 92733083..afc28629 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -1,4 +1,5 @@ import React, { MouseEventHandler, forwardRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; import FocusTrap from 'focus-trap-react'; import { Box, @@ -69,6 +70,10 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { RoomSettingsPage } from '../../state/roomSettings'; +import { useLivekitSupport } from '../../hooks/useLivekitSupport'; +import { useCallMembers, useCallSession } from '../../hooks/useCall'; +import { useDmCallStart } from '../../hooks/useDmCallStart'; +import { callEmbedAtom } from '../../state/callEmbed'; type RoomMenuProps = { room: Room; @@ -255,6 +260,59 @@ const RoomMenu = forwardRef(({ room, requestClose ); }); +function DmCallButton({ room }: { room: Room }) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const startDmCall = useDmCallStart(); + const livekitSupported = useLivekitSupport(); + const session = useCallSession(room); + const members = useCallMembers(room, session); + const currentEmbed = useAtomValue(callEmbedAtom); + + const myUserId = mx.getSafeUserId(); + const inCallHere = currentEmbed?.roomId === room.roomId; + if (inCallHere) return null; + + const inCallElsewhere = !!currentEmbed && currentEmbed.roomId !== room.roomId; + const ongoingByOthers = members.length > 0 && !members.some((m) => m.sender === myUserId); + + const disabled = !livekitSupported || inCallElsewhere; + let tooltipText: string; + if (!livekitSupported) tooltipText = t('Call.unavailable'); + else if (inCallElsewhere) tooltipText = t('Call.busy_other_room'); + else if (ongoingByOthers) tooltipText = t('Call.join'); + else tooltipText = t('Call.start'); + + const handleClick = () => { + if (disabled) return; + startDmCall(room.roomId); + }; + + return ( + + {tooltipText} + + } + > + {(triggerRef) => ( + + + + )} + + ); +} + export function RoomViewHeader({ callView }: { callView?: boolean }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -382,6 +440,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { + {direct && } {!encryptedRoom && ( { const rtcSession = mx.matrixRTC.getRoomSession(room); const ongoing = MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0; - const intent = CallEmbed.getIntent(dm, ongoing); + const intent = CallEmbed.getIntent(dm, ongoing, voiceOnly); const widget = CallEmbed.getWidget(mx, room, intent, themeKind); - const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound); + const controlState = + pref && + new CallControlState(pref.microphone, voiceOnly ? false : pref.video, pref.sound); - const embed = new CallEmbed(mx, room, widget, container, controlState); + const embed = new CallEmbed(mx, room, widget, container, controlState, voiceOnly); return embed; }; -export const useCallStart = (dm = false) => { +export const useCallStart = (dm = false, voiceOnly = false) => { const mx = useMatrixClient(); const theme = useTheme(); const setCallEmbed = useSetAtom(callEmbedAtom); @@ -69,11 +72,11 @@ export const useCallStart = (dm = false) => { if (!container) { throw new Error('Failed to start call, No embed container element found!'); } - const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref); + const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref, voiceOnly); setCallEmbed(callEmbed); }, - [mx, dm, theme, setCallEmbed, callEmbedRef] + [mx, dm, voiceOnly, theme, setCallEmbed, callEmbedRef] ); return startCall; diff --git a/src/app/hooks/useDmCallStart.ts b/src/app/hooks/useDmCallStart.ts new file mode 100644 index 00000000..5ad7acd6 --- /dev/null +++ b/src/app/hooks/useDmCallStart.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from './useMatrixClient'; +import { useCallStart } from './useCallEmbed'; +import { useCallPreferencesAtom } from '../state/hooks/callPreferences'; + +export const useDmCallStart = () => { + const mx = useMatrixClient(); + const startCall = useCallStart(true, true); + const callPref = useAtomValue(useCallPreferencesAtom()); + + return useCallback( + (roomId: string) => { + const room = mx.getRoom(roomId); + if (!room) { + // eslint-disable-next-line no-console + console.warn('[dm-call] room not found', roomId); + return; + } + startCall(room, callPref); + }, + [mx, startCall, callPref] + ); +}; diff --git a/src/app/pages/CallStatusRenderer.tsx b/src/app/pages/CallStatusRenderer.tsx index 2836886e..841b9db8 100644 --- a/src/app/pages/CallStatusRenderer.tsx +++ b/src/app/pages/CallStatusRenderer.tsx @@ -12,7 +12,12 @@ export function CallStatusRenderer() { if (!callEmbed) return null; - if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null; + if ( + screenSize === ScreenSize.Mobile && + callEmbed.roomId === selectedRoom && + !callEmbed.voiceOnly + ) + return null; return ; } diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 88daf54f..cdecce2c 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -37,6 +37,8 @@ export class CallEmbed { public joined = false; + public readonly voiceOnly: boolean; + public readonly control: CallControl; private readonly container: HTMLElement; @@ -55,12 +57,18 @@ export class CallEmbed { private readonly boundOnToDeviceEvent = this.onToDeviceEvent.bind(this); - static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent { + static getIntent(dm: boolean, ongoing: boolean, voiceOnly = false): ElementCallIntent { if (ongoing) { - return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting; + if (dm) { + return voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM; + } + return ElementCallIntent.JoinExisting; } - return dm ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCall; + if (dm) { + return voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM; + } + return ElementCallIntent.StartCall; } static getWidget( @@ -133,7 +141,8 @@ export class CallEmbed { room: Room, widget: Widget, container: HTMLElement, - initialControlState?: CallControlState + initialControlState?: CallControlState, + voiceOnly = false ) { const iframe = CallEmbed.getIframe( widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() }) @@ -148,6 +157,7 @@ export class CallEmbed { this.room = room; this.iframe = iframe; this.container = container; + this.voiceOnly = voiceOnly; const controlState = initialControlState ?? new CallControlState(true, false, true); this.control = new CallControl(controlState, call, iframe);