300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Box, Icon, IconButton, Icons, Scroll, Text, Tooltip, TooltipProvider, color } from 'folds';
|
|
import { useSetAtom } from 'jotai';
|
|
import { useCall } from './useCall';
|
|
import { useRoomCallMembers } from './useRoomCallMembers';
|
|
import { CallState } from './types';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { pendingUndockDragAtom } from './pendingDragState';
|
|
import { useIsCallDockedSidebar } from './useDockedCall';
|
|
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { UserAvatar } from '../../components/user-avatar';
|
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|
import * as css from './CallOverlay.css';
|
|
|
|
/**
|
|
* Formats call duration in MM:SS format
|
|
*/
|
|
function formatDuration(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
const secsStr = secs < 10 ? `0${secs}` : `${secs}`;
|
|
return `${mins}:${secsStr}`;
|
|
}
|
|
|
|
/**
|
|
* Sidebar docked call panel - renders at bottom of room list
|
|
*/
|
|
export function SidebarDockedCallPanel() {
|
|
const isCallDockedSidebar = useIsCallDockedSidebar();
|
|
const { activeCall, endCall, toggleMute, toggleDeafen } = useCall();
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const selectedRoomId = useSelectedRoom();
|
|
const { navigateRoom } = useRoomNavigate();
|
|
const [duration, setDuration] = useState(0);
|
|
const [showParticipants, setShowParticipants] = useState(true);
|
|
const [, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
|
|
const setPendingDrag = useSetAtom(pendingUndockDragAtom);
|
|
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
|
|
const myUserId = mx.getUserId() ?? '';
|
|
|
|
useEffect(() => {
|
|
if (!activeCall || activeCall.state !== CallState.Connected) {
|
|
setDuration(0);
|
|
return undefined;
|
|
}
|
|
|
|
const { startTime } = activeCall;
|
|
const updateDuration = () => {
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
setDuration(elapsed);
|
|
};
|
|
|
|
updateDuration();
|
|
const interval = setInterval(updateDuration, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [activeCall]);
|
|
|
|
/**
|
|
* Checks if a participant is currently speaking
|
|
*/
|
|
const isParticipantSpeaking = (userId: string): boolean => {
|
|
if (!activeCall) return false;
|
|
if (activeCall.activeSpeakers.has(userId)) return true;
|
|
|
|
const member = callMembers.find((m) => m.userId === userId);
|
|
if (!member) return false;
|
|
|
|
return Array.from(activeCall.activeSpeakers).some((speakerId) => {
|
|
const underscoreIndex = speakerId.lastIndexOf('_');
|
|
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
|
|
return deviceId === member.deviceId;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Checks if a participant is currently muted
|
|
*/
|
|
const isParticipantMuted = (userId: string): boolean => {
|
|
if (!activeCall) return false;
|
|
if (userId === myUserId) return activeCall.isMuted;
|
|
|
|
const member = callMembers.find((m) => m.userId === userId);
|
|
if (!member) return false;
|
|
|
|
return Array.from(activeCall.mutedParticipants).some((mutedId) => {
|
|
const underscoreIndex = mutedId.lastIndexOf('_');
|
|
const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId;
|
|
return deviceId === member.deviceId;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Handle mousedown on header to start drag-to-undock
|
|
*/
|
|
const handleHeaderMouseDown = async (e: React.MouseEvent) => {
|
|
if ((e.target as HTMLElement).closest('button')) return;
|
|
|
|
if (document.fullscreenElement) {
|
|
try {
|
|
await document.exitFullscreen();
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
|
|
const header = e.currentTarget as HTMLElement;
|
|
const rect = header.getBoundingClientRect();
|
|
|
|
setPendingDrag({
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
offsetX: e.clientX - rect.left,
|
|
offsetY: e.clientY - rect.top,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
setIsDocked(false);
|
|
};
|
|
|
|
if (!isCallDockedSidebar || !activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) {
|
|
return null;
|
|
}
|
|
|
|
const isConnecting = activeCall.state === CallState.Connecting;
|
|
const room = mx.getRoom(activeCall.roomId);
|
|
const allParticipants = callMembers.map((m) => m.userId);
|
|
const participantCount = allParticipants.length;
|
|
|
|
return (
|
|
<div className={css.SidebarDockedCallContainer}>
|
|
{/* Header - drag to undock */}
|
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
|
<div
|
|
className={css.SidebarDockedCallHeader}
|
|
onMouseDown={handleHeaderMouseDown}
|
|
>
|
|
<Box alignItems="Center" gap="200" style={{ minWidth: 0, flex: 1 }}>
|
|
<Icon size="100" src={Icons.Phone} />
|
|
<Text size="T300" truncate>
|
|
{isConnecting ? 'Connecting...' : room?.name ?? 'Call'}
|
|
</Text>
|
|
{selectedRoomId === activeCall.roomId && (
|
|
<Icon size="100" src={Icons.Check} style={{ opacity: 0.7 }} />
|
|
)}
|
|
</Box>
|
|
<Box alignItems="Center" gap="100">
|
|
<Text size="T200" style={{ opacity: 0.7 }}>
|
|
{formatDuration(duration)}
|
|
</Text>
|
|
{selectedRoomId !== activeCall.roomId && (
|
|
<TooltipProvider
|
|
position="Top"
|
|
offset={4}
|
|
tooltip={<Tooltip><Text>Go to Room</Text></Tooltip>}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton
|
|
ref={triggerRef}
|
|
onClick={() => navigateRoom(activeCall.roomId)}
|
|
variant="Secondary"
|
|
size="300"
|
|
>
|
|
<Icon size="100" src={Icons.Link} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
</Box>
|
|
</div>
|
|
|
|
{/* Participants */}
|
|
<div className={css.ParticipantsSection}>
|
|
<button
|
|
type="button"
|
|
className={css.ParticipantsHeader}
|
|
onClick={() => setShowParticipants(!showParticipants)}
|
|
>
|
|
<Icon size="100" src={Icons.User} style={{ opacity: 0.7 }} />
|
|
<Text size="T200" className={css.ParticipantsHeaderText}>
|
|
{participantCount} Participant{participantCount !== 1 ? 's' : ''}
|
|
</Text>
|
|
<Icon
|
|
size="100"
|
|
src={showParticipants ? Icons.ChevronTop : Icons.ChevronBottom}
|
|
style={{ opacity: 0.7 }}
|
|
/>
|
|
</button>
|
|
{showParticipants && room && (
|
|
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '150px' }}>
|
|
<div className={css.ParticipantsList}>
|
|
{allParticipants.map((participantId) => {
|
|
const avatarMxc = getMemberAvatarMxc(room, participantId);
|
|
const avatarUrl = avatarMxc
|
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
|
|
: undefined;
|
|
const displayName = getMemberDisplayName(room, participantId)
|
|
?? getMxIdLocalPart(participantId)
|
|
?? participantId;
|
|
const isMe = participantId === myUserId;
|
|
const isMuted = isParticipantMuted(participantId);
|
|
const isSpeaking = isParticipantSpeaking(participantId);
|
|
|
|
return (
|
|
<div key={participantId} className={css.ParticipantItem}>
|
|
<div
|
|
className={`${css.ParticipantAvatarWrapper} ${isSpeaking ? css.ParticipantSpeaking : ''}`}
|
|
>
|
|
<UserAvatar
|
|
className={css.ParticipantAvatar}
|
|
userId={participantId}
|
|
src={avatarUrl}
|
|
alt={displayName}
|
|
renderFallback={() => (
|
|
<Text size="H6" style={{ color: color.Surface.Container }}>
|
|
{displayName.charAt(0).toUpperCase()}
|
|
</Text>
|
|
)}
|
|
/>
|
|
</div>
|
|
<Box direction="Column" style={{ minWidth: 0, flex: 1 }}>
|
|
<Text size="T200" truncate>
|
|
{displayName}
|
|
{isMe && <span className={css.YouBadge}> (You)</span>}
|
|
</Text>
|
|
</Box>
|
|
{isMuted && (
|
|
<Icon size="100" src={Icons.MicMute} style={{ opacity: 0.5 }} />
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Scroll>
|
|
)}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className={css.SidebarDockedCallControls}>
|
|
<TooltipProvider
|
|
position="Top"
|
|
offset={4}
|
|
tooltip={<Tooltip><Text>{activeCall.isMuted ? 'Unmute' : 'Mute'}</Text></Tooltip>}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton
|
|
ref={triggerRef}
|
|
onClick={toggleMute}
|
|
variant={activeCall.isMuted ? 'Critical' : 'Secondary'}
|
|
size="300"
|
|
>
|
|
<Icon size="200" src={activeCall.isMuted ? Icons.MicMute : Icons.Mic} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider
|
|
position="Top"
|
|
offset={4}
|
|
tooltip={<Tooltip><Text>{activeCall.isDeafened ? 'Undeafen' : 'Deafen'}</Text></Tooltip>}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton
|
|
ref={triggerRef}
|
|
onClick={toggleDeafen}
|
|
variant={activeCall.isDeafened ? 'Critical' : 'Secondary'}
|
|
size="300"
|
|
>
|
|
<Icon size="200" src={activeCall.isDeafened ? Icons.VolumeMute : Icons.VolumeHigh} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider
|
|
position="Top"
|
|
offset={4}
|
|
tooltip={<Tooltip><Text>End Call</Text></Tooltip>}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton
|
|
ref={triggerRef}
|
|
onClick={endCall}
|
|
variant="Critical"
|
|
size="300"
|
|
>
|
|
<Icon size="200" src={Icons.Phone} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|