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

This commit is contained in:
heaven 2026-04-18 23:31:42 +03:00
parent 30c59e199d
commit dfcd69f1df
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!", "rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
"create": "Create" "create": "Create"
}, },
"Call": {
"start": "Start call",
"join": "Join call",
"unavailable": "Calls are unavailable",
"busy_other_room": "You are already in a call"
},
"Room": { "Room": {
"new_messages": "New Messages", "new_messages": "New Messages",
"jump_to_unread": "Jump to Unread", "jump_to_unread": "Jump to Unread",

View file

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

View file

@ -1,6 +1,5 @@
import React, { ReactNode, useCallback, useRef } from 'react'; import React, { ReactNode, useCallback, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { config } from 'folds';
import { import {
CallEmbedContextProvider, CallEmbedContextProvider,
CallEmbedRefContextProvider, CallEmbedRefContextProvider,
@ -43,7 +42,12 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop; 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 ( return (
<CallEmbedContextProvider value={callEmbed}> <CallEmbedContextProvider value={callEmbed}>
@ -53,6 +57,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
data-call-embed-container data-call-embed-container
style={{ style={{
visibility: callVisible ? undefined : 'hidden', visibility: callVisible ? undefined : 'hidden',
pointerEvents: callVisible ? undefined : 'none',
position: 'fixed', position: 'fixed',
top: 0, top: 0,
left: 0, left: 0,

View file

@ -185,6 +185,8 @@ export function CallControl({
onToggle={() => callEmbed.control.toggleSound()} onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined} disabled={!callJoined}
/> />
{!callEmbed.voiceOnly && (
<>
{!compact && <StatusDivider />} {!compact && <StatusDivider />}
<VideoButton <VideoButton
enabled={video} enabled={video}
@ -198,6 +200,8 @@ export function CallControl({
disabled={!callJoined} disabled={!callJoined}
/> />
)} )}
</>
)}
</Box> </Box>
<StatusDivider /> <StatusDivider />
<Chip <Chip

View file

@ -1,4 +1,5 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react'; import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Box, Box,
@ -69,6 +70,10 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings'; 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 = { type RoomMenuProps = {
room: Room; 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 }) { export function RoomViewHeader({ callView }: { callView?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -382,6 +440,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
{direct && <DmCallButton room={room} />}
{!encryptedRoom && ( {!encryptedRoom && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"

View file

@ -42,22 +42,25 @@ export const createCallEmbed = (
dm: boolean, dm: boolean,
themeKind: ElementCallThemeKind, themeKind: ElementCallThemeKind,
container: HTMLElement, container: HTMLElement,
pref?: CallPreferences pref?: CallPreferences,
voiceOnly = false
): CallEmbed => { ): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room); const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing = const ongoing =
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0; 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 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; return embed;
}; };
export const useCallStart = (dm = false) => { export const useCallStart = (dm = false, voiceOnly = false) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const theme = useTheme(); const theme = useTheme();
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
@ -69,11 +72,11 @@ export const useCallStart = (dm = false) => {
if (!container) { if (!container) {
throw new Error('Failed to start call, No embed container element found!'); 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); setCallEmbed(callEmbed);
}, },
[mx, dm, theme, setCallEmbed, callEmbedRef] [mx, dm, voiceOnly, theme, setCallEmbed, callEmbedRef]
); );
return startCall; 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 (!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} />; return <CallStatus callEmbed={callEmbed} />;
} }

View file

@ -37,6 +37,8 @@ export class CallEmbed {
public joined = false; public joined = false;
public readonly voiceOnly: boolean;
public readonly control: CallControl; public readonly control: CallControl;
private readonly container: HTMLElement; private readonly container: HTMLElement;
@ -55,12 +57,18 @@ export class CallEmbed {
private readonly boundOnToDeviceEvent = this.onToDeviceEvent.bind(this); 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) { 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( static getWidget(
@ -133,7 +141,8 @@ export class CallEmbed {
room: Room, room: Room,
widget: Widget, widget: Widget,
container: HTMLElement, container: HTMLElement,
initialControlState?: CallControlState initialControlState?: CallControlState,
voiceOnly = false
) { ) {
const iframe = CallEmbed.getIframe( const iframe = CallEmbed.getIframe(
widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() }) widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() })
@ -148,6 +157,7 @@ export class CallEmbed {
this.room = room; this.room = room;
this.iframe = iframe; this.iframe = iframe;
this.container = container; this.container = container;
this.voiceOnly = voiceOnly;
const controlState = initialControlState ?? new CallControlState(true, false, true); const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe); this.control = new CallControl(controlState, call, iframe);