feat(voice): add Discord-style user bar controls (#88)

Add voice state management, device picker, connection
panel, and mic/headphone/settings controls to sidebar
footer. Voice channels are now clickable (UI state only,
no WebRTC yet).

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-15 20:22:24 -07:00 committed by GitHub
parent d4914c1a46
commit 143e6b2c46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 577 additions and 26 deletions

View File

@ -26,6 +26,7 @@ import { NativeShell } from "@/components/native/native-shell"
import { PushNotificationRegistrar } from "@/hooks/use-native-push" import { PushNotificationRegistrar } from "@/hooks/use-native-push"
import { DesktopShell } from "@/components/desktop/desktop-shell" import { DesktopShell } from "@/components/desktop/desktop-shell"
import { DesktopOfflineBanner } from "@/components/desktop/offline-banner" import { DesktopOfflineBanner } from "@/components/desktop/offline-banner"
import { VoiceProvider } from "@/components/voice/voice-provider"
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@ -45,6 +46,7 @@ export default async function DashboardLayout({
return ( return (
<ChatProvider> <ChatProvider>
<VoiceProvider>
<SettingsProvider> <SettingsProvider>
<ProjectListProvider projects={projectList}> <ProjectListProvider projects={projectList}>
<PageActionsProvider> <PageActionsProvider>
@ -90,6 +92,7 @@ export default async function DashboardLayout({
</PageActionsProvider> </PageActionsProvider>
</ProjectListProvider> </ProjectListProvider>
</SettingsProvider> </SettingsProvider>
</VoiceProvider>
</ChatProvider> </ChatProvider>
) )
} }

View File

@ -19,8 +19,10 @@ import { NavFiles } from "@/components/nav-files"
import { NavProjects } from "@/components/nav-projects" import { NavProjects } from "@/components/nav-projects"
import { NavConversations } from "@/components/nav-conversations" import { NavConversations } from "@/components/nav-conversations"
import { NavUser } from "@/components/nav-user" import { NavUser } from "@/components/nav-user"
import { VoicePanel } from "@/components/voice/voice-panel"
// settings is now a page at /dashboard/settings // settings is now a page at /dashboard/settings
import { openFeedbackDialog } from "@/components/feedback-widget" import { openFeedbackDialog } from "@/components/feedback-widget"
import { useVoiceState } from "@/hooks/use-voice-state"
import type { SidebarUser } from "@/lib/auth" import type { SidebarUser } from "@/lib/auth"
import { import {
Sidebar, Sidebar,
@ -148,6 +150,7 @@ export function AppSidebar({
readonly user: SidebarUser | null readonly user: SidebarUser | null
}) { }) {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const { channelId } = useVoiceState()
return ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>
@ -198,6 +201,7 @@ export function AppSidebar({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
)} )}
{channelId !== null && <VoicePanel />}
<NavUser user={user} /> <NavUser user={user} />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>

View File

@ -1,25 +1,35 @@
"use client" "use client"
import * as React from "react"
import { Volume2 } from "lucide-react" import { Volume2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { SidebarMenuButton } from "@/components/ui/sidebar" import { SidebarMenuButton } from "@/components/ui/sidebar"
import { useVoiceState } from "@/hooks/use-voice-state"
type VoiceChannelStubProps = { type VoiceChannelStubProps = {
readonly id: string
readonly name: string readonly name: string
} }
export function VoiceChannelStub({ name }: VoiceChannelStubProps) { export function VoiceChannelStub({ id, name }: VoiceChannelStubProps): React.ReactElement {
const { channelId, joinChannel, leaveChannel } = useVoiceState()
const isActive = channelId === id
function handleClick(): void {
if (isActive) {
leaveChannel()
} else {
joinChannel(id, name)
}
}
return ( return (
<SidebarMenuButton <SidebarMenuButton
className="cursor-not-allowed opacity-60" onClick={handleClick}
disabled isActive={isActive}
tooltip={`${name} (Coming Soon)`} tooltip={name}
> >
<Volume2 className="h-4 w-4" /> <Volume2 className="h-4 w-4" />
<span>{name}</span> <span>{name}</span>
<Badge variant="secondary" className="ml-auto text-[10px]">
Soon
</Badge>
</SidebarMenuButton> </SidebarMenuButton>
) )
} }

View File

@ -279,7 +279,7 @@ export function NavConversations() {
<SidebarMenu> <SidebarMenu>
{voiceChannels.map((channel) => ( {voiceChannels.map((channel) => (
<SidebarMenuItem key={channel.id}> <SidebarMenuItem key={channel.id}>
<VoiceChannelStub name={channel.name} /> <VoiceChannelStub id={channel.id} name={channel.name} />
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>

View File

@ -1,12 +1,18 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import Link from "next/link"
import { import {
IconCreditCard, IconCreditCard,
IconDotsVertical,
IconLogout, IconLogout,
IconNotification, IconNotification,
IconUserCircle, IconUserCircle,
IconMicrophone,
IconMicrophoneOff,
IconHeadphones,
IconHeadphonesOff,
IconSettings,
IconChevronUp,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { logout } from "@/app/actions/profile" import { logout } from "@/app/actions/profile"
@ -31,26 +37,50 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { AccountModal } from "@/components/account-modal" import { AccountModal } from "@/components/account-modal"
import { DevicePicker } from "@/components/voice/device-picker"
import { useVoiceState } from "@/hooks/use-voice-state"
import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/utils" import { getInitials } from "@/lib/utils"
import type { SidebarUser } from "@/lib/auth" import type { SidebarUser } from "@/lib/auth"
function stopEvent(e: React.MouseEvent | React.PointerEvent): void {
e.stopPropagation()
e.preventDefault()
}
export function NavUser({ export function NavUser({
user, user,
}: { }: {
readonly user: SidebarUser | null readonly user: SidebarUser | null
}) { }): React.ReactElement | null {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const [accountOpen, setAccountOpen] = React.useState(false) const [accountOpen, setAccountOpen] = React.useState(false)
const {
isMuted,
isDeafened,
inputDeviceId,
outputDeviceId,
inputDevices,
outputDevices,
toggleMute,
toggleDeafen,
setInputDevice,
setOutputDevice,
} = useVoiceState()
// Don't render if no user (shouldn't happen in authenticated routes)
if (!user) { if (!user) {
return null return null
} }
const initials = getInitials(user.name) const initials = getInitials(user.name)
async function handleLogout() { async function handleLogout(): Promise<void> {
await logout() await logout()
} }
@ -63,17 +93,51 @@ export function NavUser({
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<Avatar className="h-8 w-8 rounded-lg grayscale"> <Avatar className="h-8 w-8 shrink-0 rounded-lg grayscale">
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />} {user.avatar && (
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback> <AvatarImage src={user.avatar} alt={user.name} />
)}
<AvatarFallback className="rounded-lg">
{initials}
</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <span className="min-w-0 flex-1 truncate text-sm font-medium text-sidebar-foreground">
<span className="text-sidebar-foreground truncate font-medium">{user.name}</span> {user.name}
<span className="text-sidebar-foreground/70 truncate text-xs"> </span>
{user.email} {/* Voice controls -- replace the old dots icon */}
</span> <div className="group-data-[collapsible=icon]:hidden flex shrink-0 items-center">
<DeviceButtonGroup
isMuted={isMuted}
onToggle={(e) => { stopEvent(e); toggleMute() }}
icon={isMuted ? IconMicrophoneOff : IconMicrophone}
label={isMuted ? "Unmute" : "Mute"}
dimmed={isMuted}
devices={inputDevices}
selectedDeviceId={inputDeviceId}
onSelectDevice={setInputDevice}
deviceLabel="Input Device"
/>
<DeviceButtonGroup
isMuted={isDeafened}
onToggle={(e) => { stopEvent(e); toggleDeafen() }}
icon={isDeafened ? IconHeadphonesOff : IconHeadphones}
label={isDeafened ? "Undeafen" : "Deafen"}
dimmed={isDeafened}
devices={outputDevices}
selectedDeviceId={outputDeviceId}
onSelectDevice={setOutputDevice}
deviceLabel="Output Device"
/>
<Link
href="/dashboard/settings"
onClick={stopEvent}
onPointerDown={stopEvent}
aria-label="Settings"
className="ml-px flex size-5 items-center justify-center rounded-sm text-sidebar-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<IconSettings className="size-3" />
</Link>
</div> </div>
<IconDotsVertical className="text-sidebar-foreground/70 ml-auto size-4" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -85,12 +149,16 @@ export function NavUser({
<DropdownMenuLabel className="p-0 font-normal"> <DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8 rounded-lg">
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />} {user.avatar && (
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback> <AvatarImage src={user.avatar} alt={user.name} />
)}
<AvatarFallback className="rounded-lg">
{initials}
</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span> <span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs"> <span className="truncate text-xs text-muted-foreground">
{user.email} {user.email}
</span> </span>
</div> </div>
@ -119,7 +187,76 @@ export function NavUser({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} user={user} /> <AccountModal
open={accountOpen}
onOpenChange={setAccountOpen}
user={user}
/>
</SidebarMenu> </SidebarMenu>
) )
} }
/**
* Tight icon+chevron pair: toggle button and device picker
* as one visual group to save horizontal space.
*/
function DeviceButtonGroup({
onToggle,
icon: Icon,
label,
dimmed,
devices,
selectedDeviceId,
onSelectDevice,
deviceLabel,
}: {
readonly isMuted: boolean
readonly onToggle: (e: React.MouseEvent) => void
readonly icon: React.ComponentType<{ className?: string }>
readonly label: string
readonly dimmed: boolean
readonly devices: MediaDeviceInfo[]
readonly selectedDeviceId: string | undefined
readonly onSelectDevice: (deviceId: string) => void
readonly deviceLabel: string
}): React.ReactElement {
return (
<div className="flex items-center">
<button
type="button"
onClick={onToggle}
onPointerDown={stopEvent}
aria-label={label}
className={cn(
"flex size-5 items-center justify-center rounded-sm transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground",
dimmed
? "text-sidebar-foreground/40"
: "text-sidebar-foreground/60",
)}
>
<Icon className="size-3" />
</button>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
onClick={stopEvent}
onPointerDown={stopEvent}
aria-label={`Select ${deviceLabel.toLowerCase()}`}
className="flex h-5 w-3 items-center justify-center rounded-sm text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<IconChevronUp className="size-2.5" />
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-64 p-0">
<DevicePicker
devices={devices}
selectedDeviceId={selectedDeviceId}
onSelectDevice={onSelectDevice}
label={deviceLabel}
/>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -0,0 +1,60 @@
"use client"
import * as React from "react"
import { IconCheck } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
type DevicePickerProps = {
readonly devices: MediaDeviceInfo[]
readonly selectedDeviceId: string | undefined
readonly onSelectDevice: (deviceId: string) => void
readonly label: string
}
function cleanDeviceLabel(label: string): string {
return label.replace(/\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/, "").trim()
}
export function DevicePicker({
devices,
selectedDeviceId,
onSelectDevice,
label,
}: DevicePickerProps): React.ReactElement {
return (
<div className="p-2">
<div className="mb-2 px-2 text-xs font-medium text-muted-foreground">
{label}
</div>
{devices.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
No devices found
</div>
) : (
<div className="space-y-0.5">
{devices.map((device) => {
const isSelected = device.deviceId === selectedDeviceId
return (
<button
key={device.deviceId}
type="button"
onClick={() => onSelectDevice(device.deviceId)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent",
isSelected && "bg-accent"
)}
>
<div className="flex size-4 shrink-0 items-center justify-center">
{isSelected && <IconCheck className="size-3.5" />}
</div>
<span className="flex-1 truncate text-left">
{cleanDeviceLabel(device.label) || "Unknown Device"}
</span>
</button>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import {
IconAntenna,
IconPhoneOff,
IconScreenShare,
IconScreenShareOff,
IconVideo,
IconVideoOff,
IconWaveSine,
IconSparkles,
} from "@tabler/icons-react"
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip"
import { useVoiceState } from "@/hooks/use-voice-state"
import { cn } from "@/lib/utils"
export function VoicePanel(): React.ReactElement {
const {
channelName,
isScreenSharing,
isCameraOn,
isNoiseSuppression,
toggleScreenShare,
toggleCamera,
toggleNoiseSuppression,
leaveChannel,
} = useVoiceState()
return (
<div className="group-data-[collapsible=icon]:hidden border-t border-sidebar-border">
{/* Connection status and disconnect */}
<div className="p-2">
<div className="mb-1 flex items-center gap-1.5 text-xs text-emerald-500">
<IconAntenna className="size-3.5" />
<span className="font-medium">Voice Connected</span>
</div>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">#{channelName}</span>
<span>2👤</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={leaveChannel}
className="flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-foreground"
aria-label="Disconnect"
>
<IconPhoneOff className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Disconnect</TooltipContent>
</Tooltip>
</div>
{/* Toggle controls row */}
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleScreenShare}
className={cn(
"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
isScreenSharing && "bg-sidebar-accent text-foreground"
)}
aria-label="Toggle screen share"
>
{isScreenSharing ? (
<IconScreenShare className="size-4" />
) : (
<IconScreenShareOff className="size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>Screen Share</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleCamera}
className={cn(
"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
isCameraOn && "bg-sidebar-accent text-foreground"
)}
aria-label="Toggle camera"
>
{isCameraOn ? (
<IconVideo className="size-4" />
) : (
<IconVideoOff className="size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>Camera</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleNoiseSuppression}
className={cn(
"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
isNoiseSuppression && "bg-sidebar-accent text-foreground"
)}
aria-label="Toggle noise suppression"
>
<IconWaveSine className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Noise Suppression</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
disabled
className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-50"
aria-label="Activities (coming soon)"
>
<IconSparkles className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Activities (coming soon)</TooltipContent>
</Tooltip>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,10 @@
"use client"
import type { ReactNode } from "react"
import { VoiceContext, useVoiceStateLogic } from "@/hooks/use-voice-state"
export function VoiceProvider({ children }: { readonly children: ReactNode }) {
const voiceState = useVoiceStateLogic()
return <VoiceContext.Provider value={voiceState}>{children}</VoiceContext.Provider>
}

View File

@ -0,0 +1,187 @@
"use client"
import { createContext, useCallback, useContext, useEffect, useState } from "react"
type VoiceState = {
// Connection
channelId: string | null
channelName: string
// Toggles
isMuted: boolean
isDeafened: boolean
isScreenSharing: boolean
isCameraOn: boolean
isNoiseSuppression: boolean
// Device selection
inputDeviceId: string | undefined
outputDeviceId: string | undefined
// Device lists (from enumerateDevices)
inputDevices: MediaDeviceInfo[]
outputDevices: MediaDeviceInfo[]
}
type VoiceActions = {
toggleMute: () => void
toggleDeafen: () => void
toggleScreenShare: () => void
toggleCamera: () => void
toggleNoiseSuppression: () => void
setInputDevice: (deviceId: string) => void
setOutputDevice: (deviceId: string) => void
joinChannel: (id: string, name: string) => void
leaveChannel: () => void
}
export type VoiceContextValue = VoiceState & VoiceActions
export const VoiceContext = createContext<VoiceContextValue | null>(null)
export function useVoiceState(): VoiceContextValue {
const context = useContext(VoiceContext)
if (!context) {
throw new Error("useVoiceState must be used within VoiceProvider")
}
return context
}
export function useVoiceStateLogic(): VoiceContextValue {
// Connection state
const [channelId, setChannelId] = useState<string | null>(null)
const [channelName, setChannelName] = useState<string>("")
// Toggle state
const [isMuted, setIsMuted] = useState<boolean>(false)
const [isDeafened, setIsDeafened] = useState<boolean>(false)
const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false)
const [isCameraOn, setIsCameraOn] = useState<boolean>(false)
const [isNoiseSuppression, setIsNoiseSuppression] = useState<boolean>(true)
// Device state
const [inputDeviceId, setInputDeviceId] = useState<string | undefined>(undefined)
const [outputDeviceId, setOutputDeviceId] = useState<string | undefined>(undefined)
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([])
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([])
// Load devices
const loadDevices = useCallback(async (): Promise<void> => {
if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) {
return
}
try {
const devices = await navigator.mediaDevices.enumerateDevices()
const inputs = devices.filter((device) => device.kind === "audioinput")
const outputs = devices.filter((device) => device.kind === "audiooutput")
setInputDevices(inputs)
setOutputDevices(outputs)
// Set defaults if not already set
if (!inputDeviceId && inputs.length > 0) {
setInputDeviceId(inputs[0].deviceId)
}
if (!outputDeviceId && outputs.length > 0) {
setOutputDeviceId(outputs[0].deviceId)
}
} catch (error) {
console.error("Failed to enumerate devices:", error)
}
}, [inputDeviceId, outputDeviceId])
// Listen for device changes
useEffect(() => {
if (typeof navigator === "undefined" || !navigator.mediaDevices) {
return
}
loadDevices()
const handleDeviceChange = (): void => {
loadDevices()
}
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange)
return () => {
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange)
}
}, [loadDevices])
// Toggle functions
const toggleMute = useCallback((): void => {
if (isDeafened) {
// Un-deafen and un-mute
setIsDeafened(false)
setIsMuted(false)
} else {
setIsMuted((prev) => !prev)
}
}, [isDeafened])
const toggleDeafen = useCallback((): void => {
setIsDeafened((prev) => {
const newDeafened = !prev
if (newDeafened) {
// Auto-mute when deafening
setIsMuted(true)
}
return newDeafened
})
}, [])
const toggleScreenShare = useCallback((): void => {
setIsScreenSharing((prev) => !prev)
}, [])
const toggleCamera = useCallback((): void => {
setIsCameraOn((prev) => !prev)
}, [])
const toggleNoiseSuppression = useCallback((): void => {
setIsNoiseSuppression((prev) => !prev)
}, [])
// Device setters
const setInputDevice = useCallback((deviceId: string): void => {
setInputDeviceId(deviceId)
}, [])
const setOutputDevice = useCallback((deviceId: string): void => {
setOutputDeviceId(deviceId)
}, [])
// Channel management
const joinChannel = useCallback((id: string, name: string): void => {
setChannelId(id)
setChannelName(name)
}, [])
const leaveChannel = useCallback((): void => {
setChannelId(null)
setChannelName("")
}, [])
return {
// State
channelId,
channelName,
isMuted,
isDeafened,
isScreenSharing,
isCameraOn,
isNoiseSuppression,
inputDeviceId,
outputDeviceId,
inputDevices,
outputDevices,
// Actions
toggleMute,
toggleDeafen,
toggleScreenShare,
toggleCamera,
toggleNoiseSuppression,
setInputDevice,
setOutputDevice,
joinChannel,
leaveChannel,
}
}