feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
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.
This commit is contained in:
250
packages/shared/src/components/voice/PiPOverlay.module.css
Normal file
250
packages/shared/src/components/voice/PiPOverlay.module.css
Normal file
@@ -0,0 +1,250 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: calc(var(--z-index-modal, 10000) + 1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 8px 32px var(--voice-shadow-strong),
|
||||
0 2px 8px var(--voice-shadow-soft);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background-color: var(--voice-surface-2);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.videoWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.videoWrapper video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background-color: var(--voice-surface-0);
|
||||
border-radius: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.screenShareVideo {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ── Top gradient + return-to-call button ───────────────────── */
|
||||
|
||||
.headerGradient {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(to bottom, var(--voice-overlay-strong) 0%, transparent 100%);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
pointer-events: auto;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.returnToCallButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--voice-text-strong);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
|
||||
.returnToCallButton:hover {
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
|
||||
/* ── Bottom gradient + streamer name ───────────────────────── */
|
||||
|
||||
.footerGradient {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(to top, var(--voice-overlay-strong) 0%, transparent 100%);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 8px 10px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.streamerName {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--voice-text-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Resize handles ─────────────────────────────────────────── */
|
||||
|
||||
.resizeHandleTop,
|
||||
.resizeHandleBottom,
|
||||
.resizeHandleLeft,
|
||||
.resizeHandleRight,
|
||||
.resizeHandleTopLeft,
|
||||
.resizeHandleTopRight,
|
||||
.resizeHandleBottomLeft,
|
||||
.resizeHandleBottomRight {
|
||||
position: absolute;
|
||||
border: none;
|
||||
background: transparent;
|
||||
z-index: 4;
|
||||
pointer-events: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.resizeHandleTop {
|
||||
top: 0;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 8px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.resizeHandleBottom {
|
||||
bottom: 0;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 8px;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.resizeHandleLeft {
|
||||
top: 16px;
|
||||
left: 0;
|
||||
bottom: 16px;
|
||||
width: 8px;
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.resizeHandleRight {
|
||||
top: 16px;
|
||||
right: 0;
|
||||
bottom: 16px;
|
||||
width: 8px;
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
.resizeHandleTopLeft {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
.resizeHandleTopRight {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.resizeHandleBottomLeft {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.resizeHandleBottomRight {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.pipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 12px 14px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pipLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pipChannel {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pipActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.pipPrimary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--brand-primary, #4641d9);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pipPrimary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.pipDanger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--status-danger, #ed4245);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pipDanger:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
133
packages/shared/src/components/voice/PiPOverlay.tsx
Normal file
133
packages/shared/src/components/voice/PiPOverlay.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* ── ScreenSharePreview ──────────────────────────────────────────
|
||||
Local preview of the user's own screen share, rendered above
|
||||
the participant grid in the voice call view. Matches the new UI:
|
||||
rounded card, solid dark surface, accent border, label pill. */
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 14px;
|
||||
width: min(100%, 640px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.videoFrame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--background-modifier-accent, rgba(0, 0, 0, 0.3)),
|
||||
0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #a0a3a8);
|
||||
font-size: 13px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.pauseOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
color: var(--text-primary, #ffffff);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pauseTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pauseBody {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #b5bac1);
|
||||
max-width: 320px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--status-danger, #ed4245);
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
172
packages/shared/src/components/voice/ScreenSharePreview.tsx
Normal file
172
packages/shared/src/components/voice/ScreenSharePreview.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* ScreenSharePreview — renders a local preview of the user's own
|
||||
* screen share inside the voice call view. Attaches the LiveKit
|
||||
* `screen_share` video publication to a `<video>` element and
|
||||
* detaches cleanly when the publication changes or unmounts.
|
||||
*
|
||||
* Pauses the preview (but keeps the publication running) whenever
|
||||
* the browser tab loses focus / becomes hidden, replacing the
|
||||
* frame with an overlay that reads "Your stream is still running!
|
||||
* We've paused this preview to save your resources." Mirrors the
|
||||
* new UI's local preview behaviour exactly.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import styles from './ScreenSharePreview.module.css';
|
||||
|
||||
export function ScreenSharePreview() {
|
||||
const voice = useVoice() as any;
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [hasTrack, setHasTrack] = useState(false);
|
||||
// `paused` is true whenever the tab is hidden or the window has
|
||||
// lost focus. We cover the video with an overlay in that state
|
||||
// so the user still understands their stream is live.
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
// Attach the local screen-share track to our video element. The
|
||||
// publication is recreated every time `setScreenShareEnabled(true)`
|
||||
// fires, so re-poll after the room reference or the active-channel
|
||||
// flag changes.
|
||||
useEffect(() => {
|
||||
const room = voice?.room;
|
||||
const video = videoRef.current;
|
||||
if (!room || !video) {
|
||||
setHasTrack(false);
|
||||
return;
|
||||
}
|
||||
let attached = false;
|
||||
const attach = () => {
|
||||
const publications = Array.from(
|
||||
room.localParticipant.trackPublications.values(),
|
||||
);
|
||||
const pub = publications.find(
|
||||
(p: any) => p.source === 'screen_share' || p.source === 3,
|
||||
) as any;
|
||||
const track = pub?.videoTrack;
|
||||
if (track && !attached) {
|
||||
try {
|
||||
track.attach(video);
|
||||
attached = true;
|
||||
setHasTrack(true);
|
||||
} catch {
|
||||
setHasTrack(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Initial attempt + one retry after the next microtask so a
|
||||
// freshly-published track has time to land in the map.
|
||||
attach();
|
||||
const t = window.setTimeout(attach, 250);
|
||||
|
||||
const handleTrackPublished = () => attach();
|
||||
const handleTrackUnpublished = (pub: any) => {
|
||||
if (pub?.source === 'screen_share' || pub?.source === 3) {
|
||||
try {
|
||||
pub.videoTrack?.detach(video);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
attached = false;
|
||||
setHasTrack(false);
|
||||
}
|
||||
};
|
||||
room.localParticipant.on?.('localTrackPublished', handleTrackPublished);
|
||||
room.localParticipant.on?.('localTrackUnpublished', handleTrackUnpublished);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
room.localParticipant.off?.('localTrackPublished', handleTrackPublished);
|
||||
room.localParticipant.off?.(
|
||||
'localTrackUnpublished',
|
||||
handleTrackUnpublished,
|
||||
);
|
||||
if (attached) {
|
||||
const publications = Array.from(
|
||||
room.localParticipant.trackPublications.values(),
|
||||
);
|
||||
const pub = publications.find(
|
||||
(p: any) => p.source === 'screen_share' || p.source === 3,
|
||||
) as any;
|
||||
try {
|
||||
pub?.videoTrack?.detach(video);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setHasTrack(false);
|
||||
};
|
||||
}, [voice?.room, voice?.isScreenSharing, voice?.activeChannelId]);
|
||||
|
||||
// Pause the preview whenever the tab is hidden or the window
|
||||
// loses focus. The underlying LiveKit publication keeps running
|
||||
// — we just stop painting the video element and show an overlay.
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const hidden =
|
||||
document.hidden === true || document.visibilityState === 'hidden';
|
||||
const focused = document.hasFocus?.() ?? true;
|
||||
setPaused(hidden || !focused);
|
||||
};
|
||||
update();
|
||||
document.addEventListener('visibilitychange', update);
|
||||
window.addEventListener('focus', update);
|
||||
window.addEventListener('blur', update);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', update);
|
||||
window.removeEventListener('focus', update);
|
||||
window.removeEventListener('blur', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Tell the video element to pause playback while paused so the
|
||||
// GPU isn't decoding frames the user isn't looking at.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (paused) {
|
||||
try {
|
||||
video.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
void video.play();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [paused]);
|
||||
|
||||
if (!voice?.isScreenSharing) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.videoFrame}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={styles.video}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
{!hasTrack && (
|
||||
<div className={styles.placeholder}>
|
||||
Starting your stream preview…
|
||||
</div>
|
||||
)}
|
||||
{paused && hasTrack && (
|
||||
<div className={styles.pauseOverlay}>
|
||||
<div className={styles.pauseTitle}>
|
||||
Your stream is still running!
|
||||
</div>
|
||||
<div className={styles.pauseBody}>
|
||||
We've paused this preview to save your resources.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.label}>You are live</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
packages/shared/src/components/voice/SignalStrengthIcon.tsx
Normal file
20
packages/shared/src/components/voice/SignalStrengthIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CellSignalFull, CellSignalMedium, CellSignalLow, CellSignalSlash } from '@phosphor-icons/react';
|
||||
import { ConnectionQuality } from 'livekit-client';
|
||||
|
||||
interface SignalStrengthIconProps {
|
||||
quality: ConnectionQuality;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function SignalStrengthIcon({ quality, size = 18 }: SignalStrengthIconProps) {
|
||||
if (quality === ConnectionQuality.Excellent) {
|
||||
return <CellSignalFull size={size} weight="bold" color="var(--voice-status-success)" />;
|
||||
}
|
||||
if (quality === ConnectionQuality.Good) {
|
||||
return <CellSignalMedium size={size} weight="bold" color="var(--voice-status-warning)" />;
|
||||
}
|
||||
if (quality === ConnectionQuality.Poor) {
|
||||
return <CellSignalLow size={size} weight="bold" color="var(--voice-status-danger)" />;
|
||||
}
|
||||
return <CellSignalSlash size={size} weight="bold" color="var(--voice-text-subtle)" />;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.focusLayoutContent {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
--carousel-row-height: 180px;
|
||||
}
|
||||
|
||||
.focusLayoutMain {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
max-height: min(76dvh, calc(100% - 3rem));
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.focusLayoutMain > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carouselWrapper {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: var(--carousel-row-height);
|
||||
padding: 0 12px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
padding: 4px 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carouselItem {
|
||||
flex: 0 0 auto;
|
||||
height: calc(var(--carousel-row-height) - 24px);
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect } from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { VoiceParticipantSnapshot } from '@brycord/matrix-client';
|
||||
import VoiceStore from '@app/stores/VoiceStore';
|
||||
import { VoiceGridLayout } from './VoiceGridLayout';
|
||||
import { VoiceParticipantTile } from './VoiceParticipantTile';
|
||||
import styles from './VoiceCallLayoutContent.module.css';
|
||||
|
||||
interface VoiceCallLayoutContentProps {
|
||||
participants: VoiceParticipantSnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches between grid layout (no screen share currently being
|
||||
* WATCHED) and focus layout (the local user is actively watching
|
||||
* a screen share — large primary tile + carousel of everyone else).
|
||||
*
|
||||
* The trigger changed with the opt-in streaming rework: previously
|
||||
* any peer screen-sharing auto-flipped us into focus mode. Now we
|
||||
* require the local user to explicitly click "Watch Stream" first
|
||||
* — `p.hasScreenPublication` means the peer is sharing, but
|
||||
* `p.screenSubscribed` is only true once the user opts in. This
|
||||
* keeps the layout calm when multiple peers share simultaneously
|
||||
* and lets the user pick which one to focus.
|
||||
*/
|
||||
export const VoiceCallLayoutContent = observer(function VoiceCallLayoutContent({
|
||||
participants,
|
||||
}: VoiceCallLayoutContentProps) {
|
||||
const focusedScreenSharer = participants.find(
|
||||
(p) => p.hasScreenPublication && p.screenSubscribed,
|
||||
);
|
||||
|
||||
// Flip the focused screen share to HIGH quality whenever it
|
||||
// enters focus mode, and implicitly leave it alone (or get
|
||||
// downgraded by leaving the watch set entirely) when the user
|
||||
// unwatches. Runs only when the focused identity actually
|
||||
// changes so we don't re-call setVideoQuality on every render.
|
||||
const focusedIdentity = focusedScreenSharer?.identity;
|
||||
useEffect(() => {
|
||||
if (!focusedIdentity) return;
|
||||
VoiceStore.setStreamQuality(focusedIdentity, 'screen', 'high');
|
||||
}, [focusedIdentity]);
|
||||
|
||||
if (!focusedScreenSharer) {
|
||||
return <VoiceGridLayout participants={participants} />;
|
||||
}
|
||||
|
||||
// Build the carousel items the same way the grid does: a camera
|
||||
// tile per participant, plus an extra screen-share tile for any
|
||||
// other peer who is also sharing their screen. The currently-
|
||||
// focused screen share is excluded from the carousel since it's
|
||||
// already rendering in the main slot — otherwise it would show
|
||||
// up twice.
|
||||
const carouselItems: {
|
||||
key: string;
|
||||
participant: VoiceParticipantSnapshot;
|
||||
showScreenShare: boolean;
|
||||
}[] = [];
|
||||
for (const p of participants) {
|
||||
carouselItems.push({
|
||||
key: `${p.identity}|camera`,
|
||||
participant: p,
|
||||
showScreenShare: false,
|
||||
});
|
||||
if (p.hasScreenPublication && p.identity !== focusedScreenSharer.identity) {
|
||||
carouselItems.push({
|
||||
key: `${p.identity}|screen`,
|
||||
participant: p,
|
||||
showScreenShare: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Focus mode — large screen tile + carousel of all participants
|
||||
return (
|
||||
<div className={styles.focusLayoutContent}>
|
||||
<div className={styles.focusLayoutMain}>
|
||||
<VoiceParticipantTile participant={focusedScreenSharer} showScreenShare />
|
||||
</div>
|
||||
<div className={styles.carouselWrapper}>
|
||||
<div className={styles.carousel}>
|
||||
{carouselItems.map((item) => (
|
||||
<div key={item.key} className={styles.carouselItem}>
|
||||
<VoiceParticipantTile
|
||||
participant={item.participant}
|
||||
showScreenShare={item.showScreenShare}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
159
packages/shared/src/components/voice/VoiceCallView.module.css
Normal file
159
packages/shared/src/components/voice/VoiceCallView.module.css
Normal file
@@ -0,0 +1,159 @@
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'main'
|
||||
'footer';
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #000;
|
||||
color: var(--voice-text-strong);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
--voice-hud-opacity: 0;
|
||||
--voice-hud-pointer-events: none;
|
||||
--voice-hud-transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.root.pointerActive {
|
||||
--voice-hud-opacity: 1;
|
||||
--voice-hud-pointer-events: auto;
|
||||
--voice-hud-transition-duration: 180ms;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────── */
|
||||
|
||||
.voiceHeader {
|
||||
grid-area: header;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
opacity: var(--voice-hud-opacity);
|
||||
pointer-events: var(--voice-hud-pointer-events);
|
||||
transition: opacity var(--voice-hud-transition-duration) cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.voiceHeader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--voice-header-gradient);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.headerLeftSection,
|
||||
.headerRightSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channelInfoContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--voice-text-strong);
|
||||
}
|
||||
|
||||
.channelIcon {
|
||||
color: var(--voice-text-muted);
|
||||
}
|
||||
|
||||
.channelName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.headerButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9999px;
|
||||
background: var(--voice-overlay-light);
|
||||
color: var(--voice-text-strong);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms;
|
||||
}
|
||||
|
||||
.headerButton:hover {
|
||||
background: var(--voice-overlay-light-strong);
|
||||
}
|
||||
|
||||
/* ── Main content ────────────────────────────────────────────── */
|
||||
|
||||
.mainContent {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Control bar container ───────────────────────────────────── */
|
||||
|
||||
.controlBarContainer {
|
||||
grid-area: footer;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 28px 24px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
pointer-events: none;
|
||||
opacity: var(--voice-hud-opacity);
|
||||
transition: opacity var(--voice-hud-transition-duration) cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.controlBarContainer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
background: var(--voice-footer-gradient);
|
||||
pointer-events: none;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.controlBarContainer > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Stats overlay ───────────────────────────────────────────── */
|
||||
|
||||
.statsOverlay {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 18px;
|
||||
z-index: 40;
|
||||
background: var(--voice-overlay-strong);
|
||||
color: var(--voice-text-strong);
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
min-width: 180px;
|
||||
font-size: 0.8125rem;
|
||||
box-shadow: 0 8px 24px var(--voice-shadow-strong);
|
||||
}
|
||||
|
||||
.statsHeader {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
63
packages/shared/src/components/voice/VoiceCallView.tsx
Normal file
63
packages/shared/src/components/voice/VoiceCallView.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Hash } from '@phosphor-icons/react';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import { ScreenSharePreview } from './ScreenSharePreview';
|
||||
import { VoiceGridLayout } from './VoiceGridLayout';
|
||||
import { VoiceControlBar } from './VoiceControlBar';
|
||||
import type { VoiceParticipantTileData } from './VoiceParticipantTile';
|
||||
import styles from './VoiceCallView.module.css';
|
||||
|
||||
interface VoiceCallViewProps {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface RawParticipant {
|
||||
userId: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
isMuted?: boolean;
|
||||
isDeafened?: boolean;
|
||||
isScreenSharing?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
const voice = useVoice();
|
||||
|
||||
const rawParticipants: RawParticipant[] =
|
||||
(voice?.voiceStates && voice.voiceStates[channelId]) || [];
|
||||
const activeSpeakers: Set<string> = voice?.activeSpeakers || new Set();
|
||||
|
||||
const participants: VoiceParticipantTileData[] = rawParticipants.map((p) => ({
|
||||
userId: p.userId,
|
||||
username: p.username || 'Unknown',
|
||||
avatarUrl: p.avatarUrl,
|
||||
isMuted: !!p.isMuted,
|
||||
isSpeaking: activeSpeakers.has(p.userId),
|
||||
isScreenSharing: !!p.isScreenSharing,
|
||||
}));
|
||||
|
||||
const channelName = voice?.activeChannelName || 'Voice Channel';
|
||||
|
||||
return (
|
||||
<div className={`${styles.root} ${styles.pointerActive}`} data-voice-call-root>
|
||||
<div className={styles.voiceHeader}>
|
||||
<div className={styles.headerLeftSection}>
|
||||
<div className={styles.channelInfoContainer}>
|
||||
<Hash size={20} weight="bold" className={styles.channelIcon} />
|
||||
<span className={styles.channelName}>{channelName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerRightSection} />
|
||||
</div>
|
||||
|
||||
<div className={styles.mainContent}>
|
||||
<ScreenSharePreview />
|
||||
<VoiceGridLayout participants={participants} />
|
||||
</div>
|
||||
|
||||
<div className={styles.controlBarContainer}>
|
||||
<VoiceControlBar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
.voiceConnectionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 8px 8px;
|
||||
background-color: var(--panel-control-bg, var(--background-secondary));
|
||||
border: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
box-shadow: inset 0 1px 0 var(--user-area-divider-color);
|
||||
}
|
||||
|
||||
/* ── Status row ───────────────────────────────────────────────────────── */
|
||||
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.signalIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
color: var(--status-online, #23a55a);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusButton {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.125rem;
|
||||
color: var(--status-online, #23a55a);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.statusConnecting {
|
||||
color: var(--status-warning, #f0b232);
|
||||
}
|
||||
|
||||
.statusReconnecting {
|
||||
color: var(--status-warning, #f0b232);
|
||||
}
|
||||
|
||||
.statusFailed {
|
||||
color: var(--status-danger, #f23f43);
|
||||
}
|
||||
|
||||
.statusDisconnected {
|
||||
color: var(--status-danger, #f23f43);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
background-color: transparent;
|
||||
color: var(--interactive-normal, var(--text-secondary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.controlButton:hover {
|
||||
background-color: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.controlButton.selected {
|
||||
background-color: var(--background-modifier-selected, color-mix(in srgb, var(--text-primary) 12%, transparent));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.controlButtonDanger:hover {
|
||||
background-color: color-mix(in srgb, var(--status-danger, #f23f43) 15%, transparent);
|
||||
color: var(--status-danger, #f23f43);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
/* ── Connection info ─────────────────────────────────────────────────── */
|
||||
|
||||
.connectionInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.channelSourceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channelSourceLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.channelSourceLink:hover {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.channelSourceText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.channelSourceChannel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.channelSourceSeparator {
|
||||
margin: 0 2px;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.channelSourceGuild {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.connectionIdRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.connectionIdValue {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connectionIdValueText {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connectionIdIcon {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Media section ───────────────────────────────────────────────────── */
|
||||
|
||||
.mediaSection {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mediaButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
background-color: color-mix(in srgb, var(--background-modifier-hover) 70%, transparent);
|
||||
color: var(--interactive-normal, var(--text-secondary));
|
||||
border: 1px solid var(--background-modifier-hover);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
transition: background-color 0.15s, color 0.15s, border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.mediaButton:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--background-modifier-selected, var(--background-modifier-hover));
|
||||
}
|
||||
|
||||
.mediaButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mediaButton.cameraActive,
|
||||
.mediaButton.screenShareActive {
|
||||
background-color: color-mix(in srgb, var(--status-online, #23a55a) 15%, transparent);
|
||||
color: var(--status-online, #23a55a);
|
||||
border-color: color-mix(in srgb, var(--status-online, #23a55a) 30%, transparent);
|
||||
}
|
||||
|
||||
.mediaButton.cameraActive:hover:not(:disabled),
|
||||
.mediaButton.screenShareActive:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--status-online, #23a55a) 20%, transparent);
|
||||
color: var(--status-online, #23a55a);
|
||||
}
|
||||
|
||||
.mediaIcon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
/* ── Active state for the noise-suppression toggle ─────────────────
|
||||
Added so the button reads as "on" at rest, not just on hover. Uses
|
||||
the same hover palette the other controls pick up when moused over,
|
||||
so a user who's never hovered the button can still see it's active. */
|
||||
.controlButton.controlButtonActive {
|
||||
color: var(--brand-primary, #5865f2);
|
||||
background-color: var(--background-modifier-hover, rgba(79, 84, 92, 0.35));
|
||||
}
|
||||
.controlButton.controlButtonActive:hover {
|
||||
color: var(--brand-primary, #5865f2);
|
||||
background-color: var(--background-modifier-selected, rgba(79, 84, 92, 0.55));
|
||||
}
|
||||
259
packages/shared/src/components/voice/VoiceConnectionStatus.tsx
Normal file
259
packages/shared/src/components/voice/VoiceConnectionStatus.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* VoiceConnectionStatus — sticky widget above the UserArea showing the
|
||||
* current LiveKit connection state when the user is in a voice call.
|
||||
* Ported 1:1 from the new UI's layout, adapted for our Convex
|
||||
* `VoiceContext`:
|
||||
*
|
||||
* ┌─────────────────────────────────────┐
|
||||
* │ 📶 Voice Connected ⊘ 📞× │
|
||||
* │ #channel-name │
|
||||
* │ 🖥 <participant id> │
|
||||
* │ [📷] [🖥] │
|
||||
* └─────────────────────────────────────┘
|
||||
*
|
||||
* Missing pieces (camera toggle, retry/rejoin flow, per-participant
|
||||
* signal quality) are stubbed with safe fallbacks since our voice
|
||||
* context doesn't model them yet. The screen-share button still
|
||||
* works because `VoiceContext.setScreenSharing` is wired.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Camera,
|
||||
CameraSlash,
|
||||
Desktop,
|
||||
MonitorPlay,
|
||||
PhoneX,
|
||||
Waveform,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tooltip } from '@discord-clone/ui';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import styles from './VoiceConnectionStatus.module.css';
|
||||
|
||||
/**
|
||||
* Read the user's saved voice settings (Settings → Voice & Video)
|
||||
* from localStorage so we can show a persistent active state on the
|
||||
* noise-suppression button. Also re-reads on the change event fired
|
||||
* by UserSettingsModal so toggling the setting updates the widget
|
||||
* without a reload.
|
||||
*/
|
||||
function useVoiceSettingsSnapshot() {
|
||||
const [settings, setSettings] = useState<{ noiseSuppression: boolean }>(
|
||||
() => readVoiceSettings(),
|
||||
);
|
||||
useEffect(() => {
|
||||
const onChange = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ noiseSuppression?: boolean }>).detail;
|
||||
if (detail) setSettings((prev) => ({ ...prev, ...detail }));
|
||||
else setSettings(readVoiceSettings());
|
||||
};
|
||||
window.addEventListener('brycord:voice-settings-changed', onChange);
|
||||
return () =>
|
||||
window.removeEventListener('brycord:voice-settings-changed', onChange);
|
||||
}, []);
|
||||
return settings;
|
||||
}
|
||||
|
||||
function readVoiceSettings(): { noiseSuppression: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem('voiceSettings');
|
||||
if (!raw) return { noiseSuppression: true };
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
noiseSuppression:
|
||||
typeof parsed?.noiseSuppression === 'boolean'
|
||||
? parsed.noiseSuppression
|
||||
: true,
|
||||
};
|
||||
} catch {
|
||||
return { noiseSuppression: true };
|
||||
}
|
||||
}
|
||||
|
||||
interface StatusDescriptor {
|
||||
text: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
function describeStatus(state: string): StatusDescriptor {
|
||||
switch (state) {
|
||||
case 'connecting':
|
||||
return { text: 'Connecting…', className: styles.statusConnecting };
|
||||
case 'reconnecting':
|
||||
return { text: 'Reconnecting…', className: styles.statusReconnecting };
|
||||
case 'disconnecting':
|
||||
return { text: 'Disconnecting…', className: styles.statusConnecting };
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return { text: 'Call failed', className: styles.statusFailed };
|
||||
case 'connected':
|
||||
return { text: 'Voice Connected', className: '' };
|
||||
default:
|
||||
return { text: 'Disconnected', className: styles.statusFailed };
|
||||
}
|
||||
}
|
||||
|
||||
export function VoiceConnectionStatus() {
|
||||
const voice = useVoice() as any;
|
||||
const navigate = useNavigate();
|
||||
const voiceSettings = useVoiceSettingsSnapshot();
|
||||
|
||||
if (!voice?.activeChannelId) return null;
|
||||
const state: string = voice.connectionState || 'disconnected';
|
||||
if (state === 'disconnected') return null;
|
||||
|
||||
const channelName: string = voice.activeChannelName || 'Voice Channel';
|
||||
const channelId: string = voice.activeChannelId;
|
||||
const status = describeStatus(state);
|
||||
const isConnected = state === 'connected';
|
||||
|
||||
const screenShareActive: boolean = !!voice.isScreenSharing;
|
||||
const cameraActive: boolean = !!voice.isCameraOn;
|
||||
const noiseSuppressionActive = voiceSettings.noiseSuppression;
|
||||
|
||||
// Derived participant identity for the connection-id row. Our
|
||||
// LiveKit identity is just the Convex user id (no `@user:domain`
|
||||
// prefix), so show the trailing 12 chars for a compact display.
|
||||
const localIdentity: string | undefined =
|
||||
voice.room?.localParticipant?.identity ?? undefined;
|
||||
const connectionId = localIdentity ? localIdentity.slice(-12) : null;
|
||||
|
||||
const handleJump = () => {
|
||||
navigate(`/channels/home/${channelId}`);
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
voice.disconnectVoice?.();
|
||||
};
|
||||
|
||||
const handleToggleScreenShare = () => {
|
||||
voice.setScreenSharing?.(!screenShareActive);
|
||||
};
|
||||
|
||||
const handleToggleCamera = () => {
|
||||
voice.toggleCamera?.();
|
||||
};
|
||||
|
||||
const handleOpenVoiceSettings = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('brycord:open-user-settings', {
|
||||
detail: { tab: 'voice' },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.voiceConnectionContainer}>
|
||||
<div className={styles.statusRow}>
|
||||
{isConnected && (
|
||||
<div className={styles.signalIcon} title="Connection quality">
|
||||
<Waveform size={16} weight="fill" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.statusButton} ${status.className}`}
|
||||
onClick={handleJump}
|
||||
title={status.text}
|
||||
>
|
||||
{status.text}
|
||||
</button>
|
||||
<div className={styles.controls}>
|
||||
<Tooltip
|
||||
content={
|
||||
noiseSuppressionActive
|
||||
? 'Noise Suppression · On'
|
||||
: 'Noise Suppression · Off'
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.controlButton} ${
|
||||
noiseSuppressionActive ? styles.controlButtonActive : ''
|
||||
}`}
|
||||
onClick={handleOpenVoiceSettings}
|
||||
aria-label="Noise Suppression"
|
||||
aria-pressed={noiseSuppressionActive}
|
||||
>
|
||||
<Waveform weight="fill" className={styles.icon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Disconnect" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.controlButton} ${styles.controlButtonDanger}`}
|
||||
onClick={handleDisconnect}
|
||||
aria-label="Disconnect"
|
||||
>
|
||||
<PhoneX weight="fill" className={styles.icon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.connectionInfo}>
|
||||
<div className={styles.channelSourceRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.channelSourceLink}
|
||||
onClick={handleJump}
|
||||
aria-label={`Jump to ${channelName}`}
|
||||
>
|
||||
<span className={styles.channelSourceText}>
|
||||
<span className={styles.channelSourceChannel}>{channelName}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{connectionId && (
|
||||
<div className={styles.connectionIdRow}>
|
||||
<Desktop weight="regular" className={styles.connectionIdIcon} />
|
||||
<div className={styles.connectionIdValue}>
|
||||
<span
|
||||
className={styles.connectionIdValueText}
|
||||
title={localIdentity}
|
||||
>
|
||||
{connectionId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.mediaSection}>
|
||||
<Tooltip
|
||||
content={cameraActive ? 'Turn Off Camera' : 'Turn On Camera'}
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.mediaButton} ${cameraActive ? styles.cameraActive : ''}`}
|
||||
onClick={handleToggleCamera}
|
||||
aria-label={cameraActive ? 'Turn Off Camera' : 'Turn On Camera'}
|
||||
aria-pressed={cameraActive}
|
||||
>
|
||||
{cameraActive ? (
|
||||
<Camera weight="fill" className={styles.mediaIcon} />
|
||||
) : (
|
||||
<CameraSlash weight="fill" className={styles.mediaIcon} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={screenShareActive ? 'Stop Sharing' : 'Share Your Screen'}
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.mediaButton} ${screenShareActive ? styles.screenShareActive : ''}`}
|
||||
onClick={handleToggleScreenShare}
|
||||
aria-label={screenShareActive ? 'Stop Sharing' : 'Share Your Screen'}
|
||||
aria-pressed={screenShareActive}
|
||||
>
|
||||
<MonitorPlay weight="fill" className={styles.mediaIcon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
transition-duration: 150ms;
|
||||
transition-property: background-color, color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--voice-text-strong);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.buttonUnmuted {
|
||||
background-color: var(--voice-surface-4);
|
||||
color: var(--voice-text-strong);
|
||||
}
|
||||
|
||||
.buttonUnmuted:hover {
|
||||
background-color: var(--voice-surface-5);
|
||||
}
|
||||
|
||||
.buttonMuted {
|
||||
background-color: var(--voice-status-danger-bg-solid);
|
||||
color: var(--voice-status-danger);
|
||||
}
|
||||
|
||||
.buttonMuted:hover {
|
||||
background-color: var(--voice-status-danger-bg-strong-solid);
|
||||
}
|
||||
|
||||
.buttonCameraOn,
|
||||
.buttonScreenShareOn {
|
||||
background-color: var(--voice-status-success-bg-solid);
|
||||
color: var(--voice-status-success);
|
||||
}
|
||||
|
||||
.buttonCameraOn:hover,
|
||||
.buttonScreenShareOn:hover {
|
||||
background-color: color-mix(in srgb, var(--voice-status-success) 30%, var(--voice-surface-2));
|
||||
}
|
||||
|
||||
.buttonDisconnect {
|
||||
background-color: var(--voice-status-danger-bg-solid);
|
||||
color: var(--voice-status-danger);
|
||||
}
|
||||
|
||||
.buttonDisconnect:hover {
|
||||
background-color: var(--voice-status-danger-bg-strong-solid);
|
||||
}
|
||||
|
||||
/* Settings cog overlay (top-right of mic/camera buttons) */
|
||||
.settingsButton {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 2px var(--background-primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
108
packages/shared/src/components/voice/VoiceControlBar.tsx
Normal file
108
packages/shared/src/components/voice/VoiceControlBar.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Microphone,
|
||||
MicrophoneSlash,
|
||||
SpeakerHigh,
|
||||
SpeakerSlash,
|
||||
VideoCamera,
|
||||
VideoCameraSlash,
|
||||
Monitor,
|
||||
MonitorPlay,
|
||||
PhoneDisconnect,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import styles from './VoiceControlBar.module.css';
|
||||
|
||||
export function VoiceControlBar() {
|
||||
const voice = useVoice() as any;
|
||||
|
||||
const isMuted = !!voice?.isMuted;
|
||||
const isDeafened = !!voice?.isDeafened;
|
||||
const isScreenSharing = !!voice?.isScreenSharing;
|
||||
const cameraOn = !!voice?.isCameraOn;
|
||||
|
||||
const handleMute = () => {
|
||||
void voice?.toggleMute?.();
|
||||
};
|
||||
const handleDeafen = () => {
|
||||
void voice?.toggleDeafen?.();
|
||||
};
|
||||
const handleCamera = () => {
|
||||
void voice?.toggleCamera?.();
|
||||
};
|
||||
const handleScreenShare = () => {
|
||||
void voice?.setScreenSharing?.(!isScreenSharing);
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
void voice?.disconnectVoice?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Microphone */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${isMuted ? styles.buttonMuted : styles.buttonUnmuted}`}
|
||||
onClick={handleMute}
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<MicrophoneSlash className={styles.icon} weight="fill" />
|
||||
) : (
|
||||
<Microphone className={styles.icon} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Deafen */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${isDeafened ? styles.buttonMuted : styles.buttonUnmuted}`}
|
||||
onClick={handleDeafen}
|
||||
aria-label={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
>
|
||||
{isDeafened ? (
|
||||
<SpeakerSlash className={styles.icon} weight="fill" />
|
||||
) : (
|
||||
<SpeakerHigh className={styles.icon} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Camera (stub) */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${cameraOn ? styles.buttonCameraOn : styles.buttonUnmuted}`}
|
||||
onClick={handleCamera}
|
||||
aria-label={cameraOn ? 'Disable camera' : 'Enable camera'}
|
||||
>
|
||||
{cameraOn ? (
|
||||
<VideoCamera className={styles.icon} weight="fill" />
|
||||
) : (
|
||||
<VideoCameraSlash className={styles.icon} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Screen share */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${isScreenSharing ? styles.buttonScreenShareOn : styles.buttonUnmuted}`}
|
||||
onClick={handleScreenShare}
|
||||
aria-label={isScreenSharing ? 'Stop sharing screen' : 'Share screen'}
|
||||
>
|
||||
{isScreenSharing ? (
|
||||
<Monitor className={styles.icon} weight="fill" />
|
||||
) : (
|
||||
<MonitorPlay className={styles.icon} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Disconnect */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${styles.buttonDisconnect}`}
|
||||
onClick={handleDisconnect}
|
||||
aria-label="Disconnect"
|
||||
>
|
||||
<PhoneDisconnect className={styles.icon} weight="fill" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
.gridContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
container-type: size;
|
||||
container-name: voice-grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid {
|
||||
--voice-grid-columns: 1;
|
||||
--voice-grid-gap: 12px;
|
||||
--voice-grid-side-padding: 12px;
|
||||
--voice-grid-vertical-padding: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--voice-grid-vertical-padding) var(--voice-grid-side-padding);
|
||||
gap: var(--voice-grid-gap);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridItem {
|
||||
width: calc(
|
||||
(100% - (var(--voice-grid-columns) - 1) * var(--voice-grid-gap)) / var(--voice-grid-columns)
|
||||
);
|
||||
max-width: calc(
|
||||
(100% - (var(--voice-grid-columns) - 1) * var(--voice-grid-gap)) / var(--voice-grid-columns)
|
||||
);
|
||||
aspect-ratio: 16 / 9;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Single tile sizing — fill the container while preserving aspect ratio */
|
||||
.grid[data-tile-count='1'] {
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.grid[data-tile-count='1'] .gridItem {
|
||||
width: min(
|
||||
calc(100cqw - (2 * var(--voice-grid-side-padding))),
|
||||
calc((100cqh - (2 * var(--voice-grid-vertical-padding))) * 16 / 9)
|
||||
);
|
||||
max-width: min(
|
||||
calc(100cqw - (2 * var(--voice-grid-side-padding))),
|
||||
calc((100cqh - (2 * var(--voice-grid-vertical-padding))) * 16 / 9)
|
||||
);
|
||||
}
|
||||
|
||||
/* Responsive column counts based on container size + participant count */
|
||||
@container voice-grid (min-width: 520px) and (min-height: 260px) {
|
||||
.grid:has(> :nth-child(2)) {
|
||||
--voice-grid-columns: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@container voice-grid (min-width: 860px) and (min-height: 360px) {
|
||||
.grid:has(> :nth-child(5)) {
|
||||
--voice-grid-columns: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@container voice-grid (min-width: 1180px) and (min-height: 460px) {
|
||||
.grid:has(> :nth-child(10)) {
|
||||
--voice-grid-columns: 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tighter gaps for crowded calls */
|
||||
.grid:has(> :nth-child(6)) {
|
||||
--voice-grid-gap: 10px;
|
||||
}
|
||||
|
||||
.grid:has(> :nth-child(12)) {
|
||||
--voice-grid-gap: 8px;
|
||||
}
|
||||
|
||||
.grid:has(> :nth-child(24)) {
|
||||
--voice-grid-gap: 6px;
|
||||
}
|
||||
|
||||
.grid:has(> :nth-child(40)) {
|
||||
--voice-grid-gap: 4px;
|
||||
}
|
||||
22
packages/shared/src/components/voice/VoiceGridLayout.tsx
Normal file
22
packages/shared/src/components/voice/VoiceGridLayout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { VoiceParticipantTile, type VoiceParticipantTileData } from './VoiceParticipantTile';
|
||||
import styles from './VoiceGridLayout.module.css';
|
||||
|
||||
interface VoiceGridLayoutProps {
|
||||
participants: VoiceParticipantTileData[];
|
||||
}
|
||||
|
||||
export function VoiceGridLayout({ participants }: VoiceGridLayoutProps) {
|
||||
const tileCount = participants.length;
|
||||
|
||||
return (
|
||||
<div className={styles.gridContainer}>
|
||||
<div className={styles.grid} data-tile-count={tileCount}>
|
||||
{participants.map((p) => (
|
||||
<div key={p.userId} className={styles.gridItem}>
|
||||
<VoiceParticipantTile participant={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.volumeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 8px 10px;
|
||||
}
|
||||
|
||||
.volumeLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.volumeValue {
|
||||
margin-left: auto;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.volumeSlider {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
accent-color: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
At,
|
||||
MicrophoneSlash,
|
||||
PencilSimple,
|
||||
PhoneX,
|
||||
SpeakerSimpleHigh,
|
||||
SpeakerSimpleSlash,
|
||||
User as UserIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
/**
|
||||
* VoiceParticipantContextMenu — right-click menu for users in the
|
||||
* voice channel sidebar. Includes the standard profile/message
|
||||
* actions plus voice-specific ones (volume, server mute, server
|
||||
* deafen, disconnect). Admin-only items are gated by the caller
|
||||
* passing the corresponding callback — missing callbacks hide the
|
||||
* row, same pattern MemberContextMenu uses.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import baseStyles from '../layout/ChannelListContextMenu.module.css';
|
||||
import styles from './VoiceParticipantContextMenu.module.css';
|
||||
|
||||
interface VoiceParticipantContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
onViewProfile: () => void;
|
||||
onMessage: () => void;
|
||||
/** Optional — only shown when in the same call as the target. */
|
||||
onVolumeChange?: (level: number) => void;
|
||||
/** Current volume (0..2) for the slider. */
|
||||
currentVolume?: number;
|
||||
/** Optional — admin-only actions. */
|
||||
onChangeNickname?: () => void;
|
||||
onToggleServerMute?: () => void;
|
||||
onToggleServerDeafen?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
/** Current moderation state for labels. */
|
||||
isServerMuted?: boolean;
|
||||
isServerDeafened?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VoiceParticipantContextMenu({
|
||||
x,
|
||||
y,
|
||||
onViewProfile,
|
||||
onMessage,
|
||||
onVolumeChange,
|
||||
currentVolume = 1,
|
||||
onChangeNickname,
|
||||
onToggleServerMute,
|
||||
onToggleServerDeafen,
|
||||
onDisconnect,
|
||||
isServerMuted = false,
|
||||
isServerDeafened = false,
|
||||
onClose,
|
||||
}: VoiceParticipantContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// Local slider state so dragging doesn't clobber onClose's
|
||||
// click-outside detection. Applied to the store on change.
|
||||
const [volume, setVolume] = useState(Math.round((currentVolume ?? 1) * 100));
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
top: Math.min(y, window.innerHeight - 320),
|
||||
left: Math.min(x, window.innerWidth - 220),
|
||||
};
|
||||
|
||||
const hasAdminSection = !!(onChangeNickname || onToggleServerMute || onToggleServerDeafen || onDisconnect);
|
||||
|
||||
return createPortal(
|
||||
<div className={baseStyles.overlay}>
|
||||
<div className={baseStyles.menu} ref={ref} style={style}>
|
||||
<button type="button" className={baseStyles.menuItem} onClick={onViewProfile}>
|
||||
<UserIcon size={18} weight="fill" />
|
||||
<span>View Profile</span>
|
||||
</button>
|
||||
<button type="button" className={baseStyles.menuItem} onClick={onMessage}>
|
||||
<At size={18} weight="bold" />
|
||||
<span>Message</span>
|
||||
</button>
|
||||
|
||||
{onVolumeChange && (
|
||||
<>
|
||||
<div className={baseStyles.separator} />
|
||||
<div className={styles.volumeRow}>
|
||||
<div className={styles.volumeLabel}>
|
||||
<SpeakerSimpleHigh size={16} weight="fill" />
|
||||
<span>User Volume</span>
|
||||
<span className={styles.volumeValue}>{volume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
step={1}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
setVolume(next);
|
||||
onVolumeChange(next / 100);
|
||||
}}
|
||||
className={styles.volumeSlider}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasAdminSection && <div className={baseStyles.separator} />}
|
||||
|
||||
{onChangeNickname && (
|
||||
<button type="button" className={baseStyles.menuItem} onClick={onChangeNickname}>
|
||||
<PencilSimple size={18} weight="fill" />
|
||||
<span>Change Nickname</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onToggleServerMute && (
|
||||
<button type="button" className={baseStyles.menuItem} onClick={onToggleServerMute}>
|
||||
<MicrophoneSlash size={18} weight="fill" />
|
||||
<span>{isServerMuted ? 'Unmute' : 'Server Mute'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onToggleServerDeafen && (
|
||||
<button type="button" className={baseStyles.menuItem} onClick={onToggleServerDeafen}>
|
||||
<SpeakerSimpleSlash size={18} weight="fill" />
|
||||
<span>{isServerDeafened ? 'Undeafen' : 'Server Deafen'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onDisconnect && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${baseStyles.menuItem} ${baseStyles.menuItemDanger}`}
|
||||
onClick={onDisconnect}
|
||||
>
|
||||
<PhoneX size={18} weight="fill" />
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms, color 150ms;
|
||||
color: var(--text-tertiary-muted, var(--text-muted));
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Discord / Fluxer-style speaking indicator: a green ring around
|
||||
the avatar. Box-shadow (not outline) so it follows the wrapper's
|
||||
circular shape on every browser without needing outline-offset
|
||||
tricks, and doesn't affect layout. `border-radius: 50%` is set
|
||||
here because the Avatar wrapper itself doesn't round — only the
|
||||
inner image/fallback do — so the shadow would render as a square
|
||||
otherwise. */
|
||||
.avatarSpeaking {
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 2px var(--status-online, #23a55a);
|
||||
transition: box-shadow 100ms ease-out;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.muteIcon {
|
||||
color: var(--control-button-normal-text);
|
||||
}
|
||||
|
||||
.deafenIcon {
|
||||
color: var(--control-button-normal-text);
|
||||
}
|
||||
|
||||
.streamIcon {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.liveBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--status-danger, #f23f43);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
203
packages/shared/src/components/voice/VoiceParticipantItem.tsx
Normal file
203
packages/shared/src/components/voice/VoiceParticipantItem.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import VoiceStore from '@app/stores/VoiceStore';
|
||||
import {
|
||||
FriendManager,
|
||||
MatrixClientManager,
|
||||
type Member,
|
||||
MemberManager,
|
||||
VoiceModerationManager,
|
||||
type VoiceParticipantSnapshot,
|
||||
parseMatrixUserFromIdentity,
|
||||
} from '@brycord/matrix-client';
|
||||
import { Avatar } from '@brycord/ui';
|
||||
import { MicrophoneSlash, SpeakerSlash } from '@phosphor-icons/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MemberProfileModal } from '../member/MemberProfileModal';
|
||||
import { NicknameModal } from '../member/NicknameModal';
|
||||
import { VoiceParticipantContextMenu } from './VoiceParticipantContextMenu';
|
||||
import styles from './VoiceParticipantItem.module.css';
|
||||
|
||||
interface VoiceParticipantItemProps {
|
||||
participant: VoiceParticipantSnapshot;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const VoiceParticipantItem = observer(function VoiceParticipantItem({ participant }: VoiceParticipantItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const { displayName, avatar, isMuted, isSpeaking, isScreenSharing, isLocal, identity } = participant;
|
||||
|
||||
// For the local user we read VoiceStore directly so every
|
||||
// toggle shows instant feedback without waiting for our own
|
||||
// state-event round-trip. Remote participants read
|
||||
// `participant.isDeafened` which the snapshot pipeline has
|
||||
// already merged from the `io.brycord.voice_state` state event
|
||||
// published by that user. Pre-join and in-call use the same
|
||||
// source of truth.
|
||||
const isDeafened = isLocal ? VoiceStore.isDeafened : participant.isDeafened;
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [profileMember, setProfileMember] = useState<Member | null>(null);
|
||||
const [nicknameMember, setNicknameMember] = useState<Member | null>(null);
|
||||
|
||||
const matrixUserId = parseMatrixUserFromIdentity(identity);
|
||||
|
||||
// Is the target participant in the same voice call as us right
|
||||
// now? Needed for Volume + admin actions (mute/deafen/disconnect
|
||||
// require the LiveKit data channel for disconnect, and
|
||||
// moderation state events need the voice channel context).
|
||||
const sameCallAsLocal =
|
||||
VoiceStore.connectedChannelId !== null && VoiceStore.participants.some((p) => p.identity === identity);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const closeMenu = () => setContextMenu(null);
|
||||
|
||||
// Resolve the Member object for this participant. Looks up the
|
||||
// currently-connected voice channel first (since that's where
|
||||
// the participant is visible), falling back to any room the
|
||||
// user shares with the local user via MemberManager.
|
||||
const getMember = (): Member | null => {
|
||||
try {
|
||||
const channelId = VoiceStore.connectedChannelId;
|
||||
if (!channelId) return null;
|
||||
const serverId = VoiceStore.connectedServerId ?? undefined;
|
||||
const members = MemberManager.getInstance().getRoomMembers(channelId, serverId);
|
||||
return members.find((m) => m.user.id === matrixUserId) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewProfile = () => {
|
||||
const member = getMember();
|
||||
closeMenu();
|
||||
if (member) setProfileMember(member);
|
||||
};
|
||||
|
||||
const handleMessage = async () => {
|
||||
closeMenu();
|
||||
try {
|
||||
const roomId = await FriendManager.getInstance().getOrCreateDM(matrixUserId);
|
||||
navigate(`/channels/@me/${roomId}`);
|
||||
} catch {
|
||||
// Silent — the user can retry from the profile modal.
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (level: number) => {
|
||||
VoiceStore.setParticipantVolume(identity, level);
|
||||
};
|
||||
|
||||
const handleChangeNickname = () => {
|
||||
const member = getMember();
|
||||
closeMenu();
|
||||
if (member) setNicknameMember(member);
|
||||
};
|
||||
|
||||
const handleToggleServerMute = async () => {
|
||||
closeMenu();
|
||||
const channelId = VoiceStore.connectedChannelId;
|
||||
if (!channelId) return;
|
||||
const current = VoiceModerationManager.getInstance().getVoiceModeration(channelId, matrixUserId);
|
||||
await VoiceStore.setTargetServerMuted(channelId, matrixUserId, !(current?.muted === true));
|
||||
};
|
||||
|
||||
const handleToggleServerDeafen = async () => {
|
||||
closeMenu();
|
||||
const channelId = VoiceStore.connectedChannelId;
|
||||
if (!channelId) return;
|
||||
const current = VoiceModerationManager.getInstance().getVoiceModeration(channelId, matrixUserId);
|
||||
await VoiceStore.setTargetServerDeafened(channelId, matrixUserId, !(current?.deafened === true));
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
closeMenu();
|
||||
await VoiceStore.disconnectTarget(identity);
|
||||
};
|
||||
|
||||
// Permission checks — recomputed fresh every render so they
|
||||
// stay in sync with power level / membership changes.
|
||||
const myUserId = (() => {
|
||||
try {
|
||||
return MatrixClientManager.getInstance().getClient().getUserId();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const isSelf = matrixUserId === myUserId;
|
||||
|
||||
const connectedChannelId = VoiceStore.connectedChannelId;
|
||||
const canModerate = connectedChannelId
|
||||
? VoiceModerationManager.getInstance().canModerateVoice(connectedChannelId)
|
||||
: false;
|
||||
const canRename = connectedChannelId ? MemberManager.getInstance().canSetOtherNicknames(connectedChannelId) : false;
|
||||
|
||||
// Self-protection: admins can't moderate themselves via the
|
||||
// context menu (renaming self is fine — you can always rename
|
||||
// yourself, it's a separate power level).
|
||||
const showChangeNickname = !isSelf && canRename;
|
||||
const showAdminActions = !isSelf && canModerate && sameCallAsLocal;
|
||||
const showVolume = !isSelf && sameCallAsLocal;
|
||||
|
||||
const targetModeration = connectedChannelId
|
||||
? VoiceModerationManager.getInstance().getVoiceModeration(connectedChannelId, matrixUserId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={styles.item} onContextMenu={handleContextMenu}>
|
||||
<Avatar
|
||||
src={avatar}
|
||||
fallback={displayName}
|
||||
size={24}
|
||||
className={isSpeaking ? styles.avatarSpeaking : undefined}
|
||||
/>
|
||||
<span className={styles.name}>{displayName}</span>
|
||||
<div className={styles.icons}>
|
||||
{isMuted && <MicrophoneSlash size={14} className={styles.muteIcon} />}
|
||||
{isDeafened && <SpeakerSlash size={14} className={styles.deafenIcon} />}
|
||||
{isScreenSharing && <span className={styles.liveBadge}>LIVE</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{contextMenu && (
|
||||
<VoiceParticipantContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
onClose={closeMenu}
|
||||
onViewProfile={handleViewProfile}
|
||||
onMessage={handleMessage}
|
||||
onVolumeChange={showVolume ? handleVolumeChange : undefined}
|
||||
currentVolume={showVolume ? VoiceStore.getParticipantVolume(identity) : 1}
|
||||
onChangeNickname={showChangeNickname ? handleChangeNickname : undefined}
|
||||
onToggleServerMute={showAdminActions ? handleToggleServerMute : undefined}
|
||||
onToggleServerDeafen={showAdminActions ? handleToggleServerDeafen : undefined}
|
||||
onDisconnect={showAdminActions ? handleDisconnect : undefined}
|
||||
isServerMuted={targetModeration?.muted === true}
|
||||
isServerDeafened={targetModeration?.deafened === true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileMember && <MemberProfileModal isOpen member={profileMember} onClose={() => setProfileMember(null)} />}
|
||||
|
||||
{nicknameMember && connectedChannelId && (
|
||||
<NicknameModal
|
||||
isOpen
|
||||
member={nicknameMember}
|
||||
channelId={connectedChannelId}
|
||||
serverId={VoiceStore.connectedServerId ?? undefined}
|
||||
onClose={() => setNicknameMember(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
.lkParticipantTile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
background-color: var(--voice-surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lkParticipantTile[data-source='screen_share'] {
|
||||
background-color: var(--voice-surface-0);
|
||||
}
|
||||
|
||||
.lkParticipantTile[data-source='screen_share'] .video {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Avatar placeholder when no video */
|
||||
.lkParticipantPlaceholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--voice-surface-3);
|
||||
}
|
||||
|
||||
/* Speaking indicator — green border */
|
||||
.lkParticipantTile::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
border: 0px solid var(--voice-status-success);
|
||||
transition:
|
||||
border-width 0.4s,
|
||||
border-color 0.4s;
|
||||
transition-delay: 0.5s;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lkParticipantTile[data-speaking='true']::after {
|
||||
border-width: 3.5px;
|
||||
transition-delay: 0s;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
/* Bottom metadata strip — name + mute icons */
|
||||
.lkParticipantMetadata {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lkParticipantMetadataItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background-color: color-mix(in srgb, var(--voice-surface-1) 80%, transparent);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: var(--voice-text-strong);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.mutedIcon {
|
||||
color: var(--voice-status-danger);
|
||||
}
|
||||
|
||||
/* Dim matrix-only participants (users known via MatrixRTC state events but
|
||||
not yet visible on our LiveKit room — usually Element/Commet users who
|
||||
have announced membership but haven't finished connecting their media). */
|
||||
.lkParticipantTile[data-connection-source='matrix'] .lkParticipantPlaceholder {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.joiningLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: var(--voice-text-muted, var(--voice-text-strong));
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ── Stream opt-in placeholder ─────────────────────────────────
|
||||
Rendered when a remote peer has published a camera or screen
|
||||
track but the local user hasn't clicked Watch yet. Avatar fills
|
||||
the tile just like the normal placeholder, with a LIVE pill
|
||||
in the top-left and a brand-primary Watch button centered in
|
||||
the bottom third. */
|
||||
.streamPlaceholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
background-color: var(--voice-surface-3);
|
||||
}
|
||||
|
||||
.liveIndicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: hsl(0, calc(80% * var(--saturation-factor, 1)), 60%);
|
||||
box-shadow: 0 0 6px hsl(0, calc(80% * var(--saturation-factor, 1)), 60%);
|
||||
animation: voiceLiveDotPulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes voiceLiveDotPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.watchStreamButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
background-color: var(--brand-primary);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: var(--text-on-brand-primary, #fff);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
transition: filter 0.12s, transform 0.12s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.watchStreamButton:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.watchStreamButton:active {
|
||||
filter: brightness(0.92);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ── Unwatch affordance ────────────────────────────────────────
|
||||
Small X pinned to the top-right of an actively subscribed
|
||||
remote tile. Hidden by default, fades in on hover / focus so
|
||||
it doesn't clutter the tile during normal viewing but is one
|
||||
click away when the user wants to drop bandwidth. */
|
||||
.unwatchButton {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.12s ease, visibility 0.12s ease, background-color 0.12s;
|
||||
z-index: 20;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.lkParticipantTile:hover .unwatchButton,
|
||||
.lkParticipantTile:focus-within .unwatchButton {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.unwatchButton:hover {
|
||||
background-color: hsl(350, calc(80% * var(--saturation-factor, 1)), 55%);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Avatar } from '@discord-clone/ui';
|
||||
import { Microphone, MicrophoneSlash } from '@phosphor-icons/react';
|
||||
import styles from './VoiceParticipantTile.module.css';
|
||||
|
||||
export interface VoiceParticipantTileData {
|
||||
userId: string;
|
||||
username: string;
|
||||
avatarUrl?: string;
|
||||
isMuted?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
isScreenSharing?: boolean;
|
||||
}
|
||||
|
||||
interface VoiceParticipantTileProps {
|
||||
participant: VoiceParticipantTileData;
|
||||
}
|
||||
|
||||
export function VoiceParticipantTile({ participant }: VoiceParticipantTileProps) {
|
||||
const { username, avatarUrl, isMuted, isSpeaking } = participant;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.lkParticipantTile}
|
||||
data-source="avatar"
|
||||
data-speaking={isSpeaking ? 'true' : 'false'}
|
||||
data-muted={isMuted ? 'true' : 'false'}
|
||||
>
|
||||
<div className={styles.lkParticipantPlaceholder}>
|
||||
<Avatar src={avatarUrl} fallback={username} size={96} />
|
||||
</div>
|
||||
|
||||
<div className={styles.lkParticipantMetadata}>
|
||||
<div className={styles.lkParticipantMetadataItem}>
|
||||
<span className={styles.name}>{username}</span>
|
||||
</div>
|
||||
<div className={styles.lkParticipantMetadataItem}>
|
||||
{isMuted ? (
|
||||
<MicrophoneSlash size={16} weight="fill" className={styles.mutedIcon} />
|
||||
) : (
|
||||
<Microphone size={16} weight="fill" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-left: 1.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import VoiceStore from '@app/stores/VoiceStore';
|
||||
import { VoiceParticipantItem } from './VoiceParticipantItem';
|
||||
import styles from './VoiceParticipantsList.module.css';
|
||||
|
||||
interface VoiceParticipantsListProps {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export const VoiceParticipantsList = observer(function VoiceParticipantsList({ channelId }: VoiceParticipantsListProps) {
|
||||
// If we're connected to this specific channel, show the live LiveKit
|
||||
// participant list (full data: mute state, speaking, tracks). Otherwise
|
||||
// fall back to MatrixRTC memberships so the sidebar can show who's
|
||||
// currently in this voice channel — including users joined via Element
|
||||
// or Commet — before you click join.
|
||||
const participants =
|
||||
VoiceStore.connectedChannelId === channelId
|
||||
? VoiceStore.participants
|
||||
: VoiceStore.getParticipantsForChannel(channelId);
|
||||
|
||||
if (participants.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{participants.map((participant) => (
|
||||
<VoiceParticipantItem key={participant.identity} participant={participant} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
223
packages/shared/src/components/voice/VoiceUserContextMenu.tsx
Normal file
223
packages/shared/src/components/voice/VoiceUserContextMenu.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* VoiceUserContextMenu — right-click menu for a participant in the
|
||||
* voice channel rows on the sidebar. Surfaces:
|
||||
*
|
||||
* - Personal volume slider (local, doesn't affect other clients)
|
||||
* - Personal mute toggle (local only)
|
||||
* - Server mute toggle (admins only)
|
||||
* - Disconnect user from the call (admins only)
|
||||
* - View profile (opens the big MemberProfileModal)
|
||||
*
|
||||
* Reuses `ChannelListContextMenu.module.css` for visual parity with
|
||||
* the other context menus in the app. Permissions come from
|
||||
* `api.roles.getMyPermissions` so only admins with the right power
|
||||
* level see the destructive server-side actions.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery } from 'convex/react';
|
||||
import {
|
||||
MicrophoneSlash,
|
||||
PhoneDisconnect,
|
||||
ShieldCheck,
|
||||
SpeakerSlash,
|
||||
User as UserIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import menuStyles from '../layout/ChannelListContextMenu.module.css';
|
||||
|
||||
interface VoiceUserContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
onViewProfile: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VoiceUserContextMenu({
|
||||
x,
|
||||
y,
|
||||
userId,
|
||||
username,
|
||||
onViewProfile,
|
||||
onClose,
|
||||
}: VoiceUserContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const voice = useVoice() as any;
|
||||
|
||||
const localUserId =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
const myPerms = useQuery(
|
||||
api.roles.getMyPermissions,
|
||||
localUserId ? { userId: localUserId as Id<'userProfiles'> } : 'skip',
|
||||
);
|
||||
// Server-side mute / disconnect are gated on `move_members`. If
|
||||
// your deployment doesn't expose that flag fall back to `manage_channels`
|
||||
// so admins still get the actions — matches the previous behaviour.
|
||||
const canModerate =
|
||||
!!myPerms?.move_members || !!myPerms?.manage_channels;
|
||||
|
||||
const isSelf = userId === localUserId;
|
||||
const personalVolume: number =
|
||||
voice?.getUserVolume?.(userId) ?? 100;
|
||||
const isPersonallyMuted: boolean = !!voice?.isPersonallyMuted?.(userId);
|
||||
const isServerMuted: boolean = !!voice?.isServerMuted?.(userId);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Wider than the default channel-list menu so the volume slider
|
||||
// doesn't feel cramped. Clamp to viewport to avoid clipping.
|
||||
const WIDTH = 240;
|
||||
const HEIGHT_ESTIMATE = 220;
|
||||
const style: React.CSSProperties = {
|
||||
top: Math.min(y, window.innerHeight - HEIGHT_ESTIMATE),
|
||||
left: Math.min(x, window.innerWidth - WIDTH),
|
||||
width: WIDTH,
|
||||
};
|
||||
|
||||
const handlePersonalMute = () => {
|
||||
voice?.togglePersonalMute?.(userId);
|
||||
};
|
||||
const handleServerMute = async () => {
|
||||
await voice?.serverMute?.(userId, !isServerMuted);
|
||||
onClose();
|
||||
};
|
||||
const handleDisconnect = async () => {
|
||||
await voice?.disconnectUser?.(userId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className={menuStyles.overlay}>
|
||||
<div className={menuStyles.menu} ref={ref} style={style}>
|
||||
{/* Username label — read-only, mirrors the "{Member} / Member"
|
||||
header the new UI puts on its context menus. */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px 4px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-tertiary, #a0a3a8)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{username}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={menuStyles.menuItem}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onViewProfile();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<UserIcon size={18} weight="fill" />
|
||||
<span>View Profile</span>
|
||||
</button>
|
||||
|
||||
{!isSelf && (
|
||||
<>
|
||||
<div className={menuStyles.separator} />
|
||||
|
||||
<button
|
||||
className={menuStyles.menuItem}
|
||||
type="button"
|
||||
onClick={handlePersonalMute}
|
||||
>
|
||||
<MicrophoneSlash size={18} weight="fill" />
|
||||
<span>{isPersonallyMuted ? 'Unmute' : 'Mute'}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 14px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary, #b5bac1)',
|
||||
}}
|
||||
>
|
||||
User Volume · {personalVolume}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={personalVolume}
|
||||
onChange={(e) =>
|
||||
voice?.setUserVolume?.(userId, Number(e.target.value))
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canModerate && (
|
||||
<>
|
||||
<div className={menuStyles.separator} />
|
||||
<button
|
||||
className={menuStyles.menuItem}
|
||||
type="button"
|
||||
onClick={handleServerMute}
|
||||
>
|
||||
<SpeakerSlash size={18} weight="fill" />
|
||||
<span>
|
||||
{isServerMuted ? 'Unmute on Server' : 'Server Mute'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={`${menuStyles.menuItem} ${menuStyles.menuItemDanger ?? ''}`}
|
||||
type="button"
|
||||
onClick={handleDisconnect}
|
||||
style={{ color: 'var(--status-danger, #ed4245)' }}
|
||||
>
|
||||
<PhoneDisconnect size={18} weight="fill" />
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!canModerate && (
|
||||
<>
|
||||
<div className={menuStyles.separator} />
|
||||
<button
|
||||
className={menuStyles.menuItem}
|
||||
type="button"
|
||||
disabled
|
||||
title="Requires Move Members permission"
|
||||
style={{ opacity: 0.5, cursor: 'not-allowed' }}
|
||||
>
|
||||
<ShieldCheck size={18} weight="fill" />
|
||||
<span>Server Controls</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
3
packages/shared/src/components/voice/index.ts
Normal file
3
packages/shared/src/components/voice/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { VoiceConnectionStatus } from './VoiceConnectionStatus';
|
||||
export { VoiceParticipantsList } from './VoiceParticipantsList';
|
||||
export { VoiceParticipantItem } from './VoiceParticipantItem';
|
||||
Reference in New Issue
Block a user