feat: Add option to start video call in DM (#2745)

* add option to start video all in DM

* show speaker icon for dm's in call status name

* show call view if call is active in room

* add Atria call ringtone

* update element call and widget api

* add option to start voice/video call in dms

* only show call button if user have permission

* allow call widget to send call notification event

* show incoming call dialog and play sound

* fix call permission checks

* allow option to start call in all rooms

* send notification when starting call in non-voice rooms

* hide header call button from voice rooms

* prevent call join if call not supported and started by other party

* update call menu style

* show call not supported message on incoming call notification

* improve the incoming call layout

* video call with right click without opening menu

* allow call widget to fetch media url

* add webRTC missing error

* improve call permission label

---------

Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
This commit is contained in:
Ajay Bura
2026-05-14 19:41:12 +10:00
committed by GitHub
parent 7d8c2c5937
commit 389d121c5d
17 changed files with 633 additions and 42 deletions
@@ -1,6 +1,6 @@
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { Chip, Text } from 'folds';
import { Chip, Icon, Icons, Text } from 'folds';
import { useAtomValue } from 'jotai';
import { useRoomName } from '../../hooks/useRoomMeta';
import { RoomIcon } from '../../components/room-avatar';
@@ -38,7 +38,11 @@ export function CallRoomName({ room }: CallRoomNameProps) {
variant="Background"
radii="Pill"
before={
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
dm ? (
<Icon size="200" src={Icons.VolumeHigh} filled />
) : (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
)
}
onClick={() => navigateRoom(room.roomId)}
>
+28 -5
View File
@@ -14,11 +14,20 @@ import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your homeserver does not support calling. But you can still join call started by others.
Your homeserver does not support calling.
</Text>
);
}
function WebRTCMissingError() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
@@ -26,16 +35,22 @@ function LivekitServerMissingMessage() {
function JoinMessage({
hasParticipant,
livekitSupported,
rtcSupported,
}: {
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (hasParticipant) return null;
if (rtcSupported === false) {
return <WebRTCMissingError />;
}
if (livekitSupported === false) {
return <LivekitServerMissingMessage />;
}
if (hasParticipant) return null;
return (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
Voice chats empty Be the first to hop in!
@@ -63,12 +78,16 @@ function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
const hasPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
@@ -77,7 +96,7 @@ function CallPrescreen() {
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const canJoin = hasPermission && (livekitSupported || hasParticipant);
const canJoin = hasPermission && livekitSupported && rtcSupported;
return (
<Scroll variant="Surface" hideTrack>
@@ -100,7 +119,11 @@ function CallPrescreen() {
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall &&
(hasPermission ? (
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
<JoinMessage
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
/>
) : (
<NoPermissionMessage />
))}
+5 -4
View File
@@ -60,6 +60,7 @@ import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
type RoomNavItemMenuProps = {
room: Room;
@@ -293,13 +294,13 @@ export function RoomNavItem({
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.event(
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
// Do not join if missing permissions or no livekit support and call is not started by others
if (!hasCallPermission || (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0)) {
// Do not join if missing permissions or no livekit support or no webRTC support
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
return;
}
@@ -378,7 +379,7 @@ export function RoomNavItem({
aria-label={notificationMode}
/>
)}
{room.isCallRoom() && callMembers.length > 0 && (
{callMembers.length > 0 && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
@@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
const permissionGroups = usePermissionGroups(room.isCallRoom());
const permissionGroups = usePermissionGroups();
const [powerEditor, setPowerEditor] = useState(false);
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { PermissionGroup } from '../../common-settings/permissions';
export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
export const usePermissionGroups = (): PermissionGroup[] => {
const groups: PermissionGroup[] = useMemo(() => {
const messagesGroup: PermissionGroup = {
name: 'Messages',
@@ -54,7 +54,7 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
state: true,
key: StateEvent.GroupCallMemberPrefix,
},
name: 'Join Call',
name: 'Start or Join Call',
},
],
};
@@ -216,13 +216,13 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
return [
messagesGroup,
...(isCallRoom ? [callSettingsGroup] : []),
callSettingsGroup,
moderationGroup,
roomOverviewGroup,
roomSettingsGroup,
otherSettingsGroup,
];
}, [isCallRoom]);
}, []);
return groups;
};
+7 -1
View File
@@ -18,12 +18,18 @@ import { CallView } from '../call/CallView';
import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
export function Room() {
const { eventId } = useParams();
const room = useRoom();
const mx = useMatrixClient();
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
const callEmbed = useCallEmbed();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
@@ -43,7 +49,7 @@ export function Room() {
)
);
const callView = room.isCallRoom();
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
return (
<PowerLevelsContextProvider value={powerLevels}>
+144 -1
View File
@@ -21,6 +21,7 @@ import {
RectCords,
Badge,
Spinner,
Button,
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk';
@@ -68,6 +69,9 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
type RoomMenuProps = {
room: Room;
@@ -253,6 +257,132 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
);
});
type CallMenuProps = {
onVoiceCall: () => void;
onVideoCall: () => void;
requestClose: () => void;
};
const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
({ requestClose, onVoiceCall, onVideoCall }, ref) => {
const handleVoice = () => {
onVoiceCall();
requestClose();
};
const handleVideo = () => {
onVideoCall();
requestClose();
};
return (
<Menu ref={ref} style={{ padding: config.space.S200, minWidth: toRem(150) }}>
<Box direction="Column" gap="200">
<Text size="L400">Start Call</Text>
<Box direction="Column" gap="200">
<Button
size="300"
variant="Success"
fill="Soft"
outlined
radii="300"
before={<Icon size="100" src={Icons.Phone} filled />}
onClick={handleVoice}
>
<Text size="B300">Voice</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
before={<Icon size="100" src={Icons.VideoCamera} filled />}
onClick={handleVideo}
>
<Text size="B300">Video</Text>
</Button>
</Box>
</Box>
</Menu>
);
}
);
function CallButton() {
const room = useRoom();
const direct = useIsDirectRoom();
const callEmbed = useCallEmbed();
const startCall = useCallStart(direct);
const callStarted = callEmbed && callEmbed.roomId === room.roomId;
const inAnotherCall = callEmbed && !callStarted;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{inAnotherCall ? (
<Text size="L400">Already in another call End the current call to join!</Text>
) : (
<Text>Call</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
variant="Surface"
fill="None"
ref={triggerRef}
onClick={handleOpenMenu}
onContextMenu={(evt) => {
evt.preventDefault();
startCall(room, {
microphone: true,
video: true,
sound: true,
});
}}
disabled={inAnotherCall || callStarted}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VideoCamera} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<CallMenu
onVideoCall={() => startCall(room, { microphone: true, video: true, sound: true })}
onVoiceCall={() => startCall(room, { microphone: true, video: false, sound: true })}
requestClose={() => setMenuAnchor(undefined)}
/>
</FocusTrap>
}
/>
</>
);
}
export function RoomViewHeader({ callView }: { callView?: boolean }) {
const navigate = useNavigate();
const mx = useMatrixClient();
@@ -260,6 +390,17 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const screenSize = useScreenSizeContext();
const room = useRoom();
const space = useSpaceOptionally();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const direct = useIsDirectRoom();
@@ -453,7 +594,9 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</FocusTrap>
}
/>
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && (
<CallButton />
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"