dm calls mvp: phase 1: outgoing DM voice call via element-call voiceOnly intent
This commit is contained in:
parent
30c59e199d
commit
dfcd69f1df
9 changed files with 148 additions and 26 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "К непрочитанным",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -185,18 +185,22 @@ export function CallControl({
|
||||||
onToggle={() => callEmbed.control.toggleSound()}
|
onToggle={() => callEmbed.control.toggleSound()}
|
||||||
disabled={!callJoined}
|
disabled={!callJoined}
|
||||||
/>
|
/>
|
||||||
{!compact && <StatusDivider />}
|
{!callEmbed.voiceOnly && (
|
||||||
<VideoButton
|
<>
|
||||||
enabled={video}
|
{!compact && <StatusDivider />}
|
||||||
onToggle={() => callEmbed.control.toggleVideo()}
|
<VideoButton
|
||||||
disabled={!callJoined}
|
enabled={video}
|
||||||
/>
|
onToggle={() => callEmbed.control.toggleVideo()}
|
||||||
{!compact && (
|
disabled={!callJoined}
|
||||||
<ScreenShareButton
|
/>
|
||||||
enabled={screenshare}
|
{!compact && (
|
||||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
<ScreenShareButton
|
||||||
disabled={!callJoined}
|
enabled={screenshare}
|
||||||
/>
|
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||||
|
disabled={!callJoined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<StatusDivider />
|
<StatusDivider />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
24
src/app/hooks/useDmCallStart.ts
Normal file
24
src/app/hooks/useDmCallStart.ts
Normal 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]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue