Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/a_dm_and_room_calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Added ability to start calls in DMs and rooms. DM calls will trigger a notification popup & ringtone (for other sable users/compatible clients, probably).
Binary file added public/sound/ringtone.webm
Binary file not shown.
31 changes: 21 additions & 10 deletions src/app/components/CallEmbedProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { ReactNode, useCallback, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAutoJoinCall } from '$hooks/useAutoJoinCall';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
useCallHangupEvent,
useCallJoined,
useCallThemeSync,
useCallMemberSoundSync,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
} from '$hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '$state/callEmbed';
import { CallEmbed } from '$plugins/call';
import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { IncomingCallModal } from './IncomingCallModal';

function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
Expand All @@ -31,6 +33,12 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
type CallEmbedProviderProps = {
children?: ReactNode;
};

function AutoJoinManager() {
useAutoJoinCall();
return null;
}

export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callEmbed = useAtomValue(callEmbedAtom);
const callEmbedRef = useRef<HTMLDivElement>(null);
Expand All @@ -46,17 +54,20 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {

return (
<CallEmbedContextProvider value={callEmbed}>
<IncomingCallModal />
{callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
<CallEmbedRefContextProvider value={callEmbedRef}>
<AutoJoinManager />
{children}
</CallEmbedRefContextProvider>

<div
data-call-embed-container
style={{
visibility: callVisible ? undefined : 'hidden',
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '50%',
zIndex: callVisible ? 10 : -1,
pointerEvents: callVisible ? 'all' : 'none',
}}
ref={callEmbedRef}
/>
Expand Down
143 changes: 143 additions & 0 deletions src/app/components/IncomingCallModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
Box,
Dialog,
Header,
IconButton,
Icon,
Icons,
Text,
Button,
Avatar,
config,
Overlay,
OverlayCenter,
OverlayBackdrop,
} from 'folds';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useRoomName } from '$hooks/useRoomMeta';
import { getRoomAvatarUrl } from '$utils/room';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '$utils/keyboard';
import { useAtom, useSetAtom } from 'jotai';
import {
autoJoinCallIntentAtom,
incomingCallRoomIdAtom,
mutedCallRoomIdAtom,
} from '$state/callEmbed';
import { RoomAvatar } from './room-avatar';

type IncomingCallInternalProps = {
room: any;
onClose: () => void;
};

export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProps) {
const mx = useMatrixClient();
const roomName = useRoomName(room);
const { navigateRoom } = useRoomNavigate();
const avatarUrl = getRoomAvatarUrl(mx, room, 96);
const setAutoJoinIntent = useSetAtom(autoJoinCallIntentAtom);
const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom);

const handleAnswer = () => {
setMutedRoomId(room.roomId);
setAutoJoinIntent(room.roomId);
onClose();
navigateRoom(room.roomId);
};

const handleDecline = async () => {
setMutedRoomId(room.roomId);
onClose();
};

return (
<Dialog variant="Surface" style={{ width: '340px' }}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Incoming Call</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

<Box style={{ padding: config.space.S600 }} direction="Column" alignItems="Center" gap="500">
<Avatar size="500">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl ?? undefined}
alt={roomName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>

<Box direction="Column" alignItems="Center" gap="100">
<Text size="L400" align="Center" truncate>
{roomName}
</Text>
<Text priority="400" size="T300" align="Center">
Incoming voice chat request
</Text>
</Box>

<Box gap="300" style={{ width: '100%' }} justifyContent="Center">
<Button
variant="Critical"
fill="Soft"
style={{ minWidth: '110px' }}
onClick={handleDecline}
>
<Text size="B400">Decline</Text>
</Button>
<Button
fill="Solid"
variant="Primary"
style={{ minWidth: '110px' }}
onClick={handleAnswer}
before={<Icon size="100" src={Icons.Phone} />}
>
<Text size="B400">Answer</Text>
</Button>
</Box>
</Box>
</Dialog>
);
}

export function IncomingCallModal() {
const [ringingRoomId, setRingingRoomId] = useAtom(incomingCallRoomIdAtom);
const mx = useMatrixClient();
const room = ringingRoomId ? mx.getRoom(ringingRoomId) : null;

if (!ringingRoomId || !room) return null;

const close = () => setRingingRoomId(null);

return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<div>
<IncomingCallInternal room={room} onClose={close} />
</div>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
91 changes: 87 additions & 4 deletions src/app/features/call/CallView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useCallback, useRef, useState } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { EventType } from '$types/matrix-sdk';
import { useCallEmbed, useCallEmbedPlacementSync, useCallJoined } from '$hooks/useCallEmbed';
Expand Down Expand Up @@ -39,13 +39,20 @@ function AlreadyInCallMessage() {
);
}

export function CallView() {
interface CallViewProps {
resizable?: boolean;
}

export function CallView({ resizable }: CallViewProps) {
const mx = useMatrixClient();
const room = useRoom();

const callViewRef = useRef<HTMLDivElement>(null);
useCallEmbedPlacementSync(callViewRef);

const [height, setHeight] = useState(380);
const isResizing = useRef(false);

const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);

Expand All @@ -62,13 +69,55 @@ export function CallView() {

const currentJoined = callEmbed?.roomId === room.roomId && callJoined;

const [isDragging, setIsDragging] = useState(false);

const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isResizing.current || !callViewRef.current) return;
const { top } = callViewRef.current.getBoundingClientRect();
setHeight(Math.max(150, Math.min(e.clientY - top, window.innerHeight * 0.8)));
}, []);

const stopResizing = useCallback(() => {
isResizing.current = false;
setIsDragging(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', stopResizing);
document.body.style.userSelect = 'auto';
}, [handleMouseMove]);

const startResizing = useCallback(() => {
isResizing.current = true;
setIsDragging(true);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', stopResizing);
document.body.style.userSelect = 'none';
}, [handleMouseMove, stopResizing]);

return (
<Box
ref={callViewRef}
className={ContainerColor({ variant: 'Surface' })}
style={{ minWidth: toRem(280) }}
grow="Yes"
className={ContainerColor({ variant: 'Surface' })}
style={{
position: 'relative',
height: resizable ? `${height}px` : undefined,
borderBottom: `1px solid var(--sable-surface-container-line)`,
zIndex: 20,
backgroundColor: currentJoined ? 'transparent' : undefined,
pointerEvents: currentJoined ? 'none' : 'all',
}}
>
{isDragging && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 50,
cursor: 'ns-resize',
pointerEvents: 'all',
}}
/>
)}
{!currentJoined && (
<Scroll variant="Surface" hideTrack>
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
Expand Down Expand Up @@ -100,6 +149,40 @@ export function CallView() {
</Box>
</Scroll>
)}
{resizable && (
<button
type="button"
onMouseDown={startResizing}
aria-label="Resize call view"
style={{
position: 'absolute',
bottom: '-4px',
left: 0,
right: 0,
height: '8px',
cursor: 'ns-resize',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
border: 'none',
padding: 0,
outline: 'none',
pointerEvents: 'all',
}}
>
<div
style={{
width: '32px',
height: '4px',
borderRadius: '2px',
background: 'var(--sable-surface-container-line)',
opacity: 0.6,
}}
/>
</button>
)}
</Box>
);
}
15 changes: 9 additions & 6 deletions src/app/features/room-nav/RoomNavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export function RoomNavItem({
};

const handleNavItemClick: MouseEventHandler<HTMLElement> = (evt) => {
if (room.isCallRoom()) {
if (room.isCallRoom() || (direct && callMembers.length > 0)) {
if (!isMobile) {
if (!isActiveCall && !callEmbed) {
startCall(
Expand Down Expand Up @@ -440,19 +440,22 @@ export function RoomNavItem({
aria-label={notificationMode}
/>
)}
{room.isCallRoom() && callMembers.length > 0 && !optionsVisible && (
{(room.isCallRoom() || direct) && callMembers.length > 0 && !optionsVisible && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
<Box alignItems="Center" gap="100">
<Icon size="50" src={Icons.Phone} color="Inherit" />
<Text as="span" size="L400" truncate>
{direct ? 'Calling' : `${callMembers.length} Live`}
</Text>
</Box>
</Badge>
)}
</Box>
</NavItemContent>
</NavButton>
{optionsVisible && (
<NavItemOptions>
{room.isCallRoom() && (
{(room.isCallRoom() || (direct && callMembers.length > 0)) && (
<TooltipProvider
position="Bottom"
offset={4}
Expand Down
Loading
Loading