dm calls mvp: phase 1: outgoing DM voice call via element-call voiceOnly intent

This commit is contained in:
v.lagerev 2026-04-18 23:31:42 +03:00
parent 755453ecda
commit c6d59a3852
9 changed files with 148 additions and 26 deletions

View file

@ -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",

View file

@ -382,6 +382,12 @@
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
"create": "Создать"
},
"Call": {
"start": "Позвонить",
"join": "Присоединиться",
"unavailable": "Звонки недоступны",
"busy_other_room": "Вы уже в другом звонке"
},
"Room": {
"new_messages": "Новые сообщения",
"jump_to_unread": "К непрочитанным",

View file

@ -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 (
<CallEmbedContextProvider value={callEmbed}>
@ -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,

View file

@ -185,18 +185,22 @@ export function CallControl({
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/>
{!compact && <StatusDivider />}
<VideoButton
enabled={video}
onToggle={() => callEmbed.control.toggleVideo()}
disabled={!callJoined}
/>
{!compact && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
disabled={!callJoined}
/>
{!callEmbed.voiceOnly && (
<>
{!compact && <StatusDivider />}
<VideoButton
enabled={video}
onToggle={() => callEmbed.control.toggleVideo()}
disabled={!callJoined}
/>
{!compact && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
disabled={!callJoined}
/>
)}
</>
)}
</Box>
<StatusDivider />

View file

@ -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<HTMLDivElement, RoomMenuProps>(({ 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 (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{tooltipText}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleClick}
disabled={disabled}
aria-label={tooltipText}
>
<Icon size="400" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
);
}
export function RoomViewHeader({ callView }: { callView?: boolean }) {
const { t } = useTranslation();
const navigate = useNavigate();
@ -382,6 +440,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</Box>
<Box shrink="No">
{direct && <DmCallButton room={room} />}
{!encryptedRoom && (
<TooltipProvider
position="Bottom"

View file

@ -42,22 +42,25 @@ export const createCallEmbed = (
dm: boolean,
themeKind: ElementCallThemeKind,
container: HTMLElement,
pref?: CallPreferences
pref?: CallPreferences,
voiceOnly = false
): CallEmbed => {
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;

View file

@ -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]
);
};

View file

@ -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 <CallStatus callEmbed={callEmbed} />;
}

View file

@ -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);