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

- 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:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View 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);
}

View 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>
);
}

View File

@@ -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;
}

View 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>
);
}

View 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)" />;
}

View File

@@ -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;
}

View File

@@ -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>
);
});

View 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;
}

View 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>
);
}

View File

@@ -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));
}

View 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>
);
}

View File

@@ -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;
}

View 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>
);
}

View File

@@ -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;
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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,
);
}

View File

@@ -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;
}

View 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)}
/>
)}
</>
);
});

View File

@@ -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%);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
});

View 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,
);
}

View File

@@ -0,0 +1,3 @@
export { VoiceConnectionStatus } from './VoiceConnectionStatus';
export { VoiceParticipantsList } from './VoiceParticipantsList';
export { VoiceParticipantItem } from './VoiceParticipantItem';