All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
134 lines
3.8 KiB
TypeScript
134 lines
3.8 KiB
TypeScript
/**
|
|
* PiPOverlay — floating voice-call status widget shown when the user
|
|
* is connected to a voice channel but has navigated to a different
|
|
* channel / DM / route. Presents the channel name, a "Return to
|
|
* channel" button, and a disconnect button. The widget is draggable
|
|
* via pointer events.
|
|
*
|
|
* The new UI's screen-share video PiP required live LiveKit track
|
|
* binding from a MobX store; our Convex/React voice context doesn't
|
|
* expose that the same way, so this is a simpler status widget
|
|
* focused on the common case: "I'm in a call, I want to get back."
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { PhoneDisconnect, ArrowRight } from '@phosphor-icons/react';
|
|
import { useVoice } from '../../contexts/VoiceContext';
|
|
import styles from './PiPOverlay.module.css';
|
|
|
|
interface Box {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
const WIDTH = 260;
|
|
const HEIGHT = 96;
|
|
|
|
function clamp(v: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, v));
|
|
}
|
|
|
|
export function PiPOverlay() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const voice = useVoice() as any;
|
|
|
|
const [box, setBox] = useState<Box>(() => ({
|
|
x: Math.max(16, window.innerWidth - WIDTH - 24),
|
|
y: Math.max(16, window.innerHeight - HEIGHT - 24),
|
|
}));
|
|
const dragRef = useRef<{ startX: number; startY: number; startBox: Box } | null>(null);
|
|
|
|
useEffect(() => {
|
|
const handleMove = (e: PointerEvent) => {
|
|
const drag = dragRef.current;
|
|
if (!drag) return;
|
|
const dx = e.clientX - drag.startX;
|
|
const dy = e.clientY - drag.startY;
|
|
setBox({
|
|
x: clamp(drag.startBox.x + dx, 0, window.innerWidth - WIDTH),
|
|
y: clamp(drag.startBox.y + dy, 0, window.innerHeight - HEIGHT),
|
|
});
|
|
};
|
|
const handleUp = () => {
|
|
dragRef.current = null;
|
|
};
|
|
window.addEventListener('pointermove', handleMove);
|
|
window.addEventListener('pointerup', handleUp);
|
|
return () => {
|
|
window.removeEventListener('pointermove', handleMove);
|
|
window.removeEventListener('pointerup', handleUp);
|
|
};
|
|
}, []);
|
|
|
|
const startDrag = useCallback(
|
|
(e: React.PointerEvent) => {
|
|
// Don't initiate a drag when the pointer lands on a button.
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('button')) return;
|
|
e.preventDefault();
|
|
dragRef.current = {
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
startBox: { ...box },
|
|
};
|
|
},
|
|
[box],
|
|
);
|
|
|
|
const activeChannelId: string | null = voice?.activeChannelId ?? null;
|
|
const activeChannelName: string | null = voice?.activeChannelName ?? null;
|
|
|
|
// Hide the overlay when not in a call or when the user is already
|
|
// looking at the active voice channel.
|
|
if (!activeChannelId) return null;
|
|
const onActiveChannelRoute =
|
|
location.pathname.includes(`/${activeChannelId}`);
|
|
if (onActiveChannelRoute) return null;
|
|
|
|
const handleReturn = () => {
|
|
navigate(`/channels/home/${activeChannelId}`);
|
|
};
|
|
const handleDisconnect = () => {
|
|
voice?.disconnect?.();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={styles.container}
|
|
style={{
|
|
transform: `translate(${box.x}px, ${box.y}px)`,
|
|
width: WIDTH,
|
|
height: HEIGHT,
|
|
}}
|
|
onPointerDown={startDrag}
|
|
>
|
|
<div className={styles.pipContent}>
|
|
<div className={styles.pipLabel}>In voice</div>
|
|
<div className={styles.pipChannel}>
|
|
#{activeChannelName || 'channel'}
|
|
</div>
|
|
<div className={styles.pipActions}>
|
|
<button
|
|
type="button"
|
|
className={styles.pipPrimary}
|
|
onClick={handleReturn}
|
|
aria-label="Return to channel"
|
|
>
|
|
<ArrowRight size={14} weight="bold" />
|
|
<span>Return</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.pipDanger}
|
|
onClick={handleDisconnect}
|
|
aria-label="Disconnect"
|
|
>
|
|
<PhoneDisconnect size={14} weight="bold" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|