diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index d91b840..b8e66a7 100755 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -26,6 +26,7 @@ import { NativeShell } from "@/components/native/native-shell" import { PushNotificationRegistrar } from "@/hooks/use-native-push" import { DesktopShell } from "@/components/desktop/desktop-shell" import { DesktopOfflineBanner } from "@/components/desktop/offline-banner" +import { VoiceProvider } from "@/components/voice/voice-provider" export default async function DashboardLayout({ children, @@ -45,6 +46,7 @@ export default async function DashboardLayout({ return ( + @@ -90,6 +92,7 @@ export default async function DashboardLayout({ + ) } diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 02a21ab..931b070 100755 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -19,8 +19,10 @@ import { NavFiles } from "@/components/nav-files" import { NavProjects } from "@/components/nav-projects" import { NavConversations } from "@/components/nav-conversations" import { NavUser } from "@/components/nav-user" +import { VoicePanel } from "@/components/voice/voice-panel" // settings is now a page at /dashboard/settings import { openFeedbackDialog } from "@/components/feedback-widget" +import { useVoiceState } from "@/hooks/use-voice-state" import type { SidebarUser } from "@/lib/auth" import { Sidebar, @@ -148,6 +150,7 @@ export function AppSidebar({ readonly user: SidebarUser | null }) { const { isMobile } = useSidebar() + const { channelId } = useVoiceState() return ( @@ -198,6 +201,7 @@ export function AppSidebar({ )} + {channelId !== null && } diff --git a/src/components/conversations/voice-channel-stub.tsx b/src/components/conversations/voice-channel-stub.tsx index c52e1db..e236f44 100644 --- a/src/components/conversations/voice-channel-stub.tsx +++ b/src/components/conversations/voice-channel-stub.tsx @@ -1,25 +1,35 @@ "use client" +import * as React from "react" import { Volume2 } from "lucide-react" -import { Badge } from "@/components/ui/badge" import { SidebarMenuButton } from "@/components/ui/sidebar" +import { useVoiceState } from "@/hooks/use-voice-state" type VoiceChannelStubProps = { + readonly id: 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 ( {name} - - Soon - ) } diff --git a/src/components/nav-conversations.tsx b/src/components/nav-conversations.tsx index abca7c1..40f70d0 100644 --- a/src/components/nav-conversations.tsx +++ b/src/components/nav-conversations.tsx @@ -279,7 +279,7 @@ export function NavConversations() { {voiceChannels.map((channel) => ( - + ))} diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 184c74a..dfaf34a 100755 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -1,12 +1,18 @@ "use client" import * as React from "react" +import Link from "next/link" import { IconCreditCard, - IconDotsVertical, IconLogout, IconNotification, IconUserCircle, + IconMicrophone, + IconMicrophoneOff, + IconHeadphones, + IconHeadphonesOff, + IconSettings, + IconChevronUp, } from "@tabler/icons-react" import { logout } from "@/app/actions/profile" @@ -31,26 +37,50 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" 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 type { SidebarUser } from "@/lib/auth" +function stopEvent(e: React.MouseEvent | React.PointerEvent): void { + e.stopPropagation() + e.preventDefault() +} + export function NavUser({ user, }: { readonly user: SidebarUser | null -}) { +}): React.ReactElement | null { const { isMobile } = useSidebar() 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) { return null } const initials = getInitials(user.name) - async function handleLogout() { + async function handleLogout(): Promise { await logout() } @@ -63,17 +93,51 @@ export function NavUser({ size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - - {user.avatar && } - {initials} + + {user.avatar && ( + + )} + + {initials} + -
- {user.name} - - {user.email} - + + {user.name} + + {/* Voice controls -- replace the old dots icon */} +
+ { stopEvent(e); toggleMute() }} + icon={isMuted ? IconMicrophoneOff : IconMicrophone} + label={isMuted ? "Unmute" : "Mute"} + dimmed={isMuted} + devices={inputDevices} + selectedDeviceId={inputDeviceId} + onSelectDevice={setInputDevice} + deviceLabel="Input Device" + /> + { stopEvent(e); toggleDeafen() }} + icon={isDeafened ? IconHeadphonesOff : IconHeadphones} + label={isDeafened ? "Undeafen" : "Deafen"} + dimmed={isDeafened} + devices={outputDevices} + selectedDeviceId={outputDeviceId} + onSelectDevice={setOutputDevice} + deviceLabel="Output Device" + /> + + +
-
- {user.avatar && } - {initials} + {user.avatar && ( + + )} + + {initials} +
{user.name} - + {user.email}
@@ -119,7 +187,76 @@ export function NavUser({ - + ) } + +/** + * 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 ( +
+ + + + + + + + + +
+ ) +} diff --git a/src/components/voice/device-picker.tsx b/src/components/voice/device-picker.tsx new file mode 100644 index 0000000..1989c23 --- /dev/null +++ b/src/components/voice/device-picker.tsx @@ -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 ( +
+
+ {label} +
+ {devices.length === 0 ? ( +
+ No devices found +
+ ) : ( +
+ {devices.map((device) => { + const isSelected = device.deviceId === selectedDeviceId + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/voice/voice-panel.tsx b/src/components/voice/voice-panel.tsx new file mode 100644 index 0000000..0bfdf0a --- /dev/null +++ b/src/components/voice/voice-panel.tsx @@ -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 ( +
+ {/* Connection status and disconnect */} +
+
+ + Voice Connected +
+
+
+ #{channelName} + 2👤 +
+ + + + + Disconnect + +
+ + {/* Toggle controls row */} +
+ + + + + Screen Share + + + + + + + Camera + + + + + + + Noise Suppression + + + + + + + Activities (coming soon) + +
+
+
+ ) +} diff --git a/src/components/voice/voice-provider.tsx b/src/components/voice/voice-provider.tsx new file mode 100644 index 0000000..7ca6789 --- /dev/null +++ b/src/components/voice/voice-provider.tsx @@ -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 {children} +} diff --git a/src/hooks/use-voice-state.ts b/src/hooks/use-voice-state.ts new file mode 100644 index 0000000..cf98835 --- /dev/null +++ b/src/hooks/use-voice-state.ts @@ -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(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(null) + const [channelName, setChannelName] = useState("") + + // Toggle state + const [isMuted, setIsMuted] = useState(false) + const [isDeafened, setIsDeafened] = useState(false) + const [isScreenSharing, setIsScreenSharing] = useState(false) + const [isCameraOn, setIsCameraOn] = useState(false) + const [isNoiseSuppression, setIsNoiseSuppression] = useState(true) + + // Device state + const [inputDeviceId, setInputDeviceId] = useState(undefined) + const [outputDeviceId, setOutputDeviceId] = useState(undefined) + const [inputDevices, setInputDevices] = useState([]) + const [outputDevices, setOutputDevices] = useState([]) + + // Load devices + const loadDevices = useCallback(async (): Promise => { + 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, + } +}