feat: floating glass navigation and action bars in modals

Replace inline Save/Discard/Reset buttons with sticky glass-bubble pills
that float at the bottom of scrollable modal content. Make SpaceSettings
tab sidebar sticky with glass material. Convert RoleEditView "Back to
roles" into a sticky glass pill at the top. UserSettings Log Out + Save
always visible in a glass pill with separator. Also includes floating
position hook and popover/tooltip improvements from prior work.
This commit is contained in:
Jannis Braun
2026-03-09 01:31:35 +01:00
parent 31db1a7bd3
commit 37f199e544
15 changed files with 389 additions and 145 deletions
@@ -1,7 +1,9 @@
import React, { useMemo, useRef, useEffect } from 'react'; import React, { useMemo, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { MemberWithUser } from '@backspace/shared'; import type { MemberWithUser } from '@backspace/shared';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
const MAX_RESULTS = 8; const MAX_RESULTS = 8;
@@ -9,13 +11,15 @@ interface MentionPopoverProps {
query: string; query: string;
selectedIndex: number; selectedIndex: number;
onSelect: (member: MemberWithUser) => void; onSelect: (member: MemberWithUser) => void;
anchorRef: React.RefObject<HTMLElement | null>;
} }
export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopoverProps) { export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: MentionPopoverProps) {
const members = useSpaceStore((s) => s.members); const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const selectedRef = useRef<HTMLDivElement>(null); const selectedRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId; const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId;
@@ -30,6 +34,12 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
.slice(0, MAX_RESULTS); .slice(0, MAX_RESULTS);
}, [members, query]); }, [members, query]);
const { style } = useFloatingPosition(anchorRef, floatingRef, {
placement: 'top',
offset: 4,
enabled: filtered.length > 0,
});
// Scroll selected item into view // Scroll selected item into view
useEffect(() => { useEffect(() => {
selectedRef.current?.scrollIntoView({ block: 'nearest' }); selectedRef.current?.scrollIntoView({ block: 'nearest' });
@@ -46,8 +56,8 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
return undefined; return undefined;
}; };
return ( return createPortal(
<div className="absolute bottom-full left-0 w-[280px] mb-1 z-50"> <div ref={floatingRef} style={style} className="w-[280px]">
<div className="bg-surface-elevated rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin"> <div className="bg-surface-elevated rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider"> <div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
Members Members
@@ -88,6 +98,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
); );
})} })}
</div> </div>
</div> </div>,
document.body,
); );
} }
@@ -24,6 +24,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [mentionState, setMentionState] = useState<MentionState | null>(null); const [mentionState, setMentionState] = useState<MentionState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage); const sendMessage = useChatStore((s) => s.sendMessage);
const replyTo = useChatStore((s) => s.replyTo); const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo); const setReplyTo = useChatStore((s) => s.setReplyTo);
@@ -249,6 +250,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
</div> </div>
)} )}
<div <div
ref={inputContainerRef}
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`} className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@@ -259,6 +261,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
query={mentionState.query} query={mentionState.query}
selectedIndex={mentionState.selectedIndex} selectedIndex={mentionState.selectedIndex}
onSelect={selectMention} onSelect={selectMention}
anchorRef={inputContainerRef}
/> />
)} )}
@@ -196,20 +196,24 @@ function StreamingLimitsPanel() {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div> <div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)} )}
{hasChanges && ( {hasChanges && (
<div className="flex items-center gap-2"> <div className="sticky bottom-0 z-10 pointer-events-none">
<button <div className="flex justify-center pt-3 pb-1">
onClick={handleSave} <div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
disabled={saving} <button
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" onClick={handleReset}
> className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
{saving ? 'Saving...' : 'Save'} >
</button> Reset
<button </button>
onClick={handleReset} <button
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors" onClick={handleSave}
> disabled={saving}
Reset className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
</button> >
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div> </div>
)} )}
</div> </div>
@@ -331,20 +335,24 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div> <div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)} )}
{hasChanges && ( {hasChanges && (
<div className="flex items-center gap-2"> <div className="sticky bottom-0 z-10 pointer-events-none">
<button <div className="flex justify-center pt-3 pb-1">
onClick={handleSave} <div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
disabled={saving} <button
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" onClick={handleReset}
> className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
{saving ? 'Saving...' : 'Save'} >
</button> Reset
<button </button>
onClick={handleReset} <button
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors" onClick={handleSave}
> disabled={saving}
Reset className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
</button> >
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div> </div>
)} )}
@@ -479,7 +487,7 @@ export function SpaceSettingsModal() {
if (!space || !currentSpaceId) return null; if (!space || !currentSpaceId) return null;
const tabClass = (t: typeof tab) => const tabClass = (t: typeof tab) =>
`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${ `w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover' tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`; }`;
@@ -487,28 +495,30 @@ export function SpaceSettingsModal() {
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl"> <Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
<div className="flex gap-4"> <div className="flex gap-4">
{/* Tabs */} {/* Tabs */}
<div className="w-32 flex-shrink-0 space-y-1"> <div className="w-32 flex-shrink-0 sticky top-0 self-start z-10">
<button onClick={() => setTab('overview')} className={tabClass('overview')}> <div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
Overview <button onClick={() => setTab('overview')} className={tabClass('overview')}>
</button> Overview
{canManageSpace && (
<button onClick={() => setTab('discovery')} className={tabClass('discovery')}>
Discovery
</button> </button>
)} {canManageSpace && (
<button onClick={() => setTab('members')} className={tabClass('members')}> <button onClick={() => setTab('discovery')} className={tabClass('discovery')}>
Members Discovery
</button> </button>
{canManageRoles && ( )}
<button onClick={() => setTab('roles')} className={tabClass('roles')}> <button onClick={() => setTab('members')} className={tabClass('members')}>
Roles Members
</button> </button>
)} {canManageRoles && (
{isAdmin && ( <button onClick={() => setTab('roles')} className={tabClass('roles')}>
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}> Roles
Streaming </button>
</button> )}
)} {isAdmin && (
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
Streaming
</button>
)}
</div>
</div> </div>
{/* Content */} {/* Content */}
@@ -172,20 +172,25 @@ export function UserSettingsModal() {
{/* Connected Instances */} {/* Connected Instances */}
<ConnectedInstances /> <ConnectedInstances />
<div className="flex items-center justify-between pt-2"> <div className="sticky bottom-0 z-10 pointer-events-none">
<button <div className="flex justify-center pt-3 pb-1">
onClick={handleLogout} <div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
className="px-4 py-2 text-sm text-txt-danger hover:bg-accent-rose/10 rounded transition-colors" <button
> onClick={handleLogout}
Log Out className="px-3 py-1 text-sm text-txt-danger hover:bg-accent-rose/10 rounded-full transition-colors"
</button> >
<button Log Out
onClick={handleSave} </button>
disabled={isLoading} <div className="w-px h-5 bg-white/10" />
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" <button
> onClick={handleSave}
{isLoading ? 'Saving...' : 'Save Changes'} disabled={isLoading}
</button> className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -229,20 +229,24 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div> <div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)} )}
{canManageSpace && hasChanges && ( {canManageSpace && hasChanges && (
<div className="flex items-center gap-2"> <div className="sticky bottom-0 z-10 pointer-events-none">
<button <div className="flex justify-center pt-3 pb-1">
onClick={handleSave} <div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
disabled={saving || uploadingIcon || !spaceName.trim()} <button
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" onClick={handleDiscard}
> className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
{saving ? 'Saving...' : 'Save'} >
</button> Discard
<button </button>
onClick={handleDiscard} <button
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors" onClick={handleSave}
> disabled={saving || uploadingIcon || !spaceName.trim()}
Discard className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
</button> >
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div> </div>
)} )}
@@ -242,15 +242,17 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Back button */} {/* Back button */}
<button <div className="sticky top-0 z-10 pointer-events-none pb-3">
onClick={onBack} <button
className="flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors" onClick={onBack}
> className="glass-bubble rounded-full px-3 py-1.5 flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors pointer-events-auto"
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> >
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
</svg> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
Back to roles </svg>
</button> Back to roles
</button>
</div>
{/* Role Name (not editable for @everyone) */} {/* Role Name (not editable for @everyone) */}
{!isEveryone && ( {!isEveryone && (
@@ -362,20 +364,24 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Role saved</div> <div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Role saved</div>
)} )}
{hasChanges && ( {hasChanges && (
<div className="flex items-center gap-2"> <div className="sticky bottom-0 z-10 pointer-events-none">
<button <div className="flex justify-center pt-3 pb-1">
onClick={handleSave} <div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
disabled={saving || (!isEveryone && !draftName.trim())} <button
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" onClick={handleDiscard}
> className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
{saving ? 'Saving...' : 'Save'} >
</button> Discard
<button </button>
onClick={handleDiscard} <button
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors" onClick={handleSave}
> disabled={saving || (!isEveryone && !draftName.trim())}
Discard className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
</button> >
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div> </div>
)} )}
@@ -49,6 +49,8 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
if (rect.bottom > window.innerHeight) { if (rect.bottom > window.innerHeight) {
newPosition.y = window.innerHeight - rect.height - 8; newPosition.y = window.innerHeight - rect.height - 8;
} }
if (newPosition.x < 8) newPosition.x = 8;
if (newPosition.y < 8) newPosition.y = 8;
if (newPosition.x !== position.x || newPosition.y !== position.y) { if (newPosition.x !== position.x || newPosition.y !== position.y) {
setPosition(newPosition); setPosition(newPosition);
@@ -62,7 +64,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
{isOpen && ( {isOpen && (
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in" className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin"
style={{ left: position.x, top: position.y }} style={{ left: position.x, top: position.y }}
> >
{items.map((item, i) => ( {items.map((item, i) => (
+3 -3
View File
@@ -30,9 +30,9 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
className="absolute inset-0 bg-surface-overlay" className="absolute inset-0 bg-surface-overlay"
onClick={onClose} onClick={onClose}
/> />
<div className={`relative ${maxWidth} w-full mx-4 bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}> <div className={`relative ${maxWidth} w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}>
{title && ( {title && (
<div className="flex items-center justify-between px-4 pt-4"> <div className="flex items-center justify-between px-4 pt-4 flex-shrink-0">
<h2 className="text-xl font-bold text-txt-primary">{title}</h2> <h2 className="text-xl font-bold text-txt-primary">{title}</h2>
<button <button
onClick={onClose} onClick={onClose}
@@ -44,7 +44,7 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
</button> </button>
</div> </div>
)} )}
<div className="p-4"> <div className="p-4 overflow-y-auto scrollbar-thin flex-1 min-h-0">
{children} {children}
</div> </div>
</div> </div>
+17 -11
View File
@@ -1,4 +1,6 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
interface TooltipProps { interface TooltipProps {
content: string; content: string;
@@ -10,6 +12,14 @@ interface TooltipProps {
export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) { export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const anchorRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
const { style } = useFloatingPosition(anchorRef, floatingRef, {
placement: position,
offset: 8,
enabled: isVisible,
});
const show = () => { const show = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay); timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
@@ -26,22 +36,18 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
}; };
}, []); }, []);
const positionClasses: Record<string, string> = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
};
return ( return (
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}> <div ref={anchorRef} className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
{children} {children}
{isVisible && ( {isVisible && createPortal(
<div <div
className={`absolute z-[200] px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`} ref={floatingRef}
style={style}
className="px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none"
> >
{content} {content}
</div> </div>,
document.body,
)} )}
</div> </div>
); );
@@ -1,10 +1,13 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats'; import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
import { getActiveRoom } from '../../hooks/useLiveKit'; import { getActiveRoom } from '../../hooks/useLiveKit';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
interface ConnectionInfoPopoverProps { interface ConnectionInfoPopoverProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
} }
function formatBitrate(kbps: number): string { function formatBitrate(kbps: number): string {
@@ -110,10 +113,16 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) {
); );
} }
export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) { export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const stats = useTrackStats(open); const stats = useTrackStats(open);
const { style } = useFloatingPosition(anchorRef, popoverRef, {
placement: 'top',
offset: 12,
enabled: open,
});
// Click-outside to close // Click-outside to close
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -130,16 +139,17 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
const room = getActiveRoom(); const room = getActiveRoom();
return ( return createPortal(
<div <div
ref={popoverRef} ref={popoverRef}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[300px] glass rounded-lg z-50 overflow-hidden" style={style}
className="w-[300px] glass rounded-lg overflow-hidden"
> >
<div className="px-3 py-2 border-b border-border-hard"> <div className="px-3 py-2 border-b border-border-hard">
<span className="text-[14px] font-bold text-txt-primary">Connection Info</span> <span className="text-[14px] font-bold text-txt-primary">Connection Info</span>
</div> </div>
<div className="px-3 py-2"> <div className="px-3 py-2 max-h-[calc(100vh-32px)] overflow-y-auto scrollbar-thin">
{!room ? ( {!room ? (
<div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div> <div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div>
) : !stats ? ( ) : !stats ? (
@@ -197,6 +207,7 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
</> </>
)} )}
</div> </div>
</div> </div>,
document.body,
); );
} }
@@ -1,12 +1,15 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import type { ScreenShareConfig } from '../../stores/voiceStore'; import type { ScreenShareConfig } from '../../stores/voiceStore';
import { useSettingsStore } from '../../stores/settingsStore'; import { useSettingsStore } from '../../stores/settingsStore';
import { buildScreenShareOptions } from '../../utils/screenShare'; import { buildScreenShareOptions } from '../../utils/screenShare';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
interface ScreenShareSettingsPopoverProps { interface ScreenShareSettingsPopoverProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
} }
const ALL_RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [ const ALL_RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [
@@ -45,12 +48,18 @@ function formatKbps(kbps: number): string {
: `${kbps} kbps`; : `${kbps} kbps`;
} }
export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSettingsPopoverProps) { export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const config = useVoiceStore((s) => s.screenShareConfig); const config = useVoiceStore((s) => s.screenShareConfig);
const setConfig = useVoiceStore((s) => s.setScreenShareConfig); const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
const limits = useSettingsStore((s) => s.streamingLimits); const limits = useSettingsStore((s) => s.streamingLimits);
const { style } = useFloatingPosition(anchorRef, popoverRef, {
placement: 'top',
offset: 12,
enabled: open,
});
const BITRATE_MIN = limits?.minBitrateKbps ?? 500; const BITRATE_MIN = limits?.minBitrateKbps ?? 500;
const BITRATE_MAX = limits?.maxBitrateKbps ?? 20000; const BITRATE_MAX = limits?.maxBitrateKbps ?? 20000;
const BITRATE_STEP = limits?.bitrateStepKbps ?? 500; const BITRATE_STEP = limits?.bitrateStepKbps ?? 500;
@@ -104,10 +113,11 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
const pillSelected = 'bg-accent-primary text-white'; const pillSelected = 'bg-accent-primary text-white';
const pillUnselected = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover'; const pillUnselected = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
return ( return createPortal(
<div <div
ref={popoverRef} ref={popoverRef}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[260px] glass rounded-lg z-50 overflow-hidden" style={style}
className="w-[260px] glass rounded-lg overflow-hidden"
> >
<div className="px-3 py-2 border-b border-border-hard"> <div className="px-3 py-2 border-b border-border-hard">
<span className="text-[14px] font-bold text-txt-primary">Stream Settings</span> <span className="text-[14px] font-bold text-txt-primary">Stream Settings</span>
@@ -215,6 +225,7 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
{formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)} {formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)}
</span> </span>
</div> </div>
</div> </div>,
document.body,
); );
} }
@@ -38,6 +38,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
// Context menu state // Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false); const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
const qualityBtnRef = useRef<HTMLButtonElement>(null);
// --- VIDEO --- use LiveKit's track.attach() to register the element // --- VIDEO --- use LiveKit's track.attach() to register the element
// with the adaptive stream observer (enables SFU layer switching by viewport size) // with the adaptive stream observer (enables SFU layer switching by viewport size)
@@ -260,6 +261,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
</div> </div>
<div className="relative"> <div className="relative">
<button <button
ref={qualityBtnRef}
onClick={() => setQualityPopoverOpen(!qualityPopoverOpen)} onClick={() => setQualityPopoverOpen(!qualityPopoverOpen)}
className="w-full flex items-center justify-between px-2 py-1.5 text-sm text-txt-secondary hover:bg-interactive-hover rounded transition-colors" className="w-full flex items-center justify-between px-2 py-1.5 text-sm text-txt-secondary hover:bg-interactive-hover rounded transition-colors"
> >
@@ -277,6 +279,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
<ScreenShareSettingsPopover <ScreenShareSettingsPopover
open={qualityPopoverOpen} open={qualityPopoverOpen}
onClose={() => setQualityPopoverOpen(false)} onClose={() => setQualityPopoverOpen(false)}
anchorRef={qualityBtnRef}
/> />
)} )}
</div> </div>
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { getActiveRoom } from '../../hooks/useLiveKit'; import { getActiveRoom } from '../../hooks/useLiveKit';
@@ -27,6 +27,7 @@ export function VoiceControlBar() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
const [qualityOpen, setQualityOpen] = useState(false); const [qualityOpen, setQualityOpen] = useState(false);
const qualityBtnRef = useRef<HTMLButtonElement>(null);
const handleMute = React.useCallback(async () => { const handleMute = React.useCallback(async () => {
toggleMic(); toggleMic();
@@ -207,21 +208,20 @@ export function VoiceControlBar() {
</button> </button>
{/* Video Quality */} {/* Video Quality */}
<div className="relative"> <button
<button ref={qualityBtnRef}
onClick={() => setQualityOpen(!qualityOpen)} onClick={() => setQualityOpen(!qualityOpen)}
className={qualityOpen className={qualityOpen
? `${btnBase} bg-surface-channel text-txt-primary` ? `${btnBase} bg-surface-channel text-txt-primary`
: btnDefault : btnDefault
} }
title="Video Quality" title="Video Quality"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg> </svg>
</button> </button>
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} /> <ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} anchorRef={qualityBtnRef} />
</div>
{/* Separator */} {/* Separator */}
<div className="w-[1px] h-6 bg-white/10 mx-0.5" /> <div className="w-[1px] h-6 bg-white/10 mx-0.5" />
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { getActiveRoom } from '../../hooks/useLiveKit'; import { getActiveRoom } from '../../hooks/useLiveKit';
@@ -24,6 +24,8 @@ export function VoiceControls() {
const channels = useSpaceStore((s) => s.channels); const channels = useSpaceStore((s) => s.channels);
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false); const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
const [showConnectionInfo, setShowConnectionInfo] = useState(false); const [showConnectionInfo, setShowConnectionInfo] = useState(false);
const connectionBtnRef = useRef<HTMLButtonElement>(null);
const qualityBtnRef = useRef<HTMLButtonElement>(null);
const activeDmCall = useVoiceStore((s) => s.activeDmCall); const activeDmCall = useVoiceStore((s) => s.activeDmCall);
@@ -109,6 +111,7 @@ export function VoiceControls() {
{/* Row 1: Signal icon + status text + disconnect */} {/* Row 1: Signal icon + status text + disconnect */}
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1"> <div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
<button <button
ref={connectionBtnRef}
onClick={() => { onClick={() => {
setShowConnectionInfo(!showConnectionInfo); setShowConnectionInfo(!showConnectionInfo);
if (!showConnectionInfo) setShowScreenShareSettings(false); if (!showConnectionInfo) setShowScreenShareSettings(false);
@@ -146,6 +149,7 @@ export function VoiceControls() {
<ConnectionInfoPopover <ConnectionInfoPopover
open={showConnectionInfo} open={showConnectionInfo}
onClose={() => setShowConnectionInfo(false)} onClose={() => setShowConnectionInfo(false)}
anchorRef={connectionBtnRef}
/> />
</div> </div>
@@ -189,6 +193,7 @@ export function VoiceControls() {
{/* Video Quality */} {/* Video Quality */}
<button <button
ref={qualityBtnRef}
onClick={() => { onClick={() => {
setShowScreenShareSettings(!showScreenShareSettings); setShowScreenShareSettings(!showScreenShareSettings);
if (!showScreenShareSettings) setShowConnectionInfo(false); if (!showScreenShareSettings) setShowConnectionInfo(false);
@@ -238,6 +243,7 @@ export function VoiceControls() {
<ScreenShareSettingsPopover <ScreenShareSettingsPopover
open={showScreenShareSettings} open={showScreenShareSettings}
onClose={() => setShowScreenShareSettings(false)} onClose={() => setShowScreenShareSettings(false)}
anchorRef={qualityBtnRef}
/> />
</div> </div>
</> </>
@@ -0,0 +1,166 @@
import { type RefObject, type CSSProperties, useState, useLayoutEffect, useCallback } from 'react';
type Placement = 'top' | 'bottom' | 'left' | 'right';
interface UseFloatingPositionOptions {
placement: Placement;
offset?: number;
enabled?: boolean;
}
interface UseFloatingPositionResult {
style: CSSProperties;
actualPlacement: Placement;
}
const VIEWPORT_PADDING = 8;
const oppositePlacement: Record<Placement, Placement> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
};
function computePosition(
anchorRect: DOMRect,
floatingWidth: number,
floatingHeight: number,
placement: Placement,
offset: number,
): { top: number; left: number; actualPlacement: Placement } {
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = 0;
let left = 0;
let actual = placement;
// Compute initial position on primary axis
if (placement === 'top') {
top = anchorRect.top - floatingHeight - offset;
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
} else if (placement === 'bottom') {
top = anchorRect.bottom + offset;
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
} else if (placement === 'left') {
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
left = anchorRect.left - floatingWidth - offset;
} else {
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
left = anchorRect.right + offset;
}
// Flip: if overflowing on primary axis, try the opposite side
if (placement === 'top' && top < VIEWPORT_PADDING) {
const flippedTop = anchorRect.bottom + offset;
if (flippedTop + floatingHeight <= vh - VIEWPORT_PADDING) {
top = flippedTop;
actual = 'bottom';
}
} else if (placement === 'bottom' && top + floatingHeight > vh - VIEWPORT_PADDING) {
const flippedTop = anchorRect.top - floatingHeight - offset;
if (flippedTop >= VIEWPORT_PADDING) {
top = flippedTop;
actual = 'top';
}
} else if (placement === 'left' && left < VIEWPORT_PADDING) {
const flippedLeft = anchorRect.right + offset;
if (flippedLeft + floatingWidth <= vw - VIEWPORT_PADDING) {
left = flippedLeft;
actual = 'right';
}
} else if (placement === 'right' && left + floatingWidth > vw - VIEWPORT_PADDING) {
const flippedLeft = anchorRect.left - floatingWidth - offset;
if (flippedLeft >= VIEWPORT_PADDING) {
left = flippedLeft;
actual = 'left';
}
}
// Clamp: keep within viewport on cross axis
if (actual === 'top' || actual === 'bottom') {
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - floatingWidth - VIEWPORT_PADDING));
} else {
top = Math.max(VIEWPORT_PADDING, Math.min(top, vh - floatingHeight - VIEWPORT_PADDING));
}
// Also clamp primary axis as last resort
top = Math.max(VIEWPORT_PADDING, Math.min(top, vh - floatingHeight - VIEWPORT_PADDING));
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - floatingWidth - VIEWPORT_PADDING));
return { top, left, actualPlacement: actual };
}
export function useFloatingPosition(
anchorRef: RefObject<HTMLElement | null>,
floatingRef: RefObject<HTMLElement | null>,
options: UseFloatingPositionOptions,
): UseFloatingPositionResult {
const { placement, offset = 8, enabled = true } = options;
const [result, setResult] = useState<{ top: number; left: number; actualPlacement: Placement }>({
top: -9999,
left: -9999,
actualPlacement: placement,
});
const update = useCallback(() => {
const anchor = anchorRef.current;
const floating = floatingRef.current;
if (!anchor || !floating) return;
const anchorRect = anchor.getBoundingClientRect();
const floatingRect = floating.getBoundingClientRect();
const pos = computePosition(
anchorRect,
floatingRect.width,
floatingRect.height,
placement,
offset,
);
setResult((prev) => {
if (prev.top === pos.top && prev.left === pos.left && prev.actualPlacement === pos.actualPlacement) {
return prev;
}
return pos;
});
}, [anchorRef, floatingRef, placement, offset]);
useLayoutEffect(() => {
if (!enabled) return;
update();
// Observe resize of both elements
const anchor = anchorRef.current;
const floating = floatingRef.current;
const targets: Element[] = [];
if (anchor) targets.push(anchor);
if (floating) targets.push(floating);
const ro = new ResizeObserver(update);
targets.forEach((t) => ro.observe(t));
// Also update on scroll/resize
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
ro.disconnect();
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [enabled, update, anchorRef, floatingRef]);
return {
style: {
position: 'fixed',
top: result.top,
left: result.left,
zIndex: 200,
},
actualPlacement: result.actualPlacement,
};
}