import type { Component } from "solid-js"; import { For, Show, createSignal } from "solid-js"; import { DragDropProvider, DragDropSensors, SortableProvider, createSortable, closestCenter, } from "@thisbeyond/solid-dnd"; import { Hash, Volume2, Plus, ChevronDown, FolderPlus, Mic, MicOff, Headphones, HeadphoneOff, PhoneOff, } from "lucide-solid"; import { channels, categories, activeChannelId, setActiveChannel, setChannels, reorderChannels, } from "../../stores/channels"; import { activeCommunity } from "../../stores/communities"; import { openModal } from "../../stores/ui"; import { voiceChannelId, voiceParticipants, isInVoice, joinVoice, leaveVoice, localMediaState, toggleMute, toggleDeafen, voiceConnectionState, } from "../../stores/voice"; import { identity } from "../../stores/identity"; import SidebarLayout from "../common/SidebarLayout"; import Avatar from "../common/Avatar"; import type { ChannelMeta } from "../../lib/types"; interface GhostInfo { channel: ChannelMeta; position: "above" | "below"; } interface SortableChannelProps { channel: ChannelMeta; isActive: boolean; isInVoiceChannel: boolean; icon: typeof Hash; onClick: () => void; ghost: GhostInfo | null; } // translucent preview of the dragged channel at its drop position const GhostChannel: Component<{ name: string; icon: typeof Hash }> = ( props, ) => (
{props.name}
); const SortableChannel: Component = (props) => { const sortable = createSortable(props.channel.id); // determine styling based on active and voice channel state const getContainerClass = () => { if (props.isInVoiceChannel) { // user is currently in this voice channel return "bg-orange/20 text-white border-l-4 border-orange pl-1"; } if (props.isActive) { return "bg-gray-800 text-white border-l-4 border-orange pl-1"; } return "text-white/60 hover:bg-gray-800 hover:text-white pl-2"; }; return ( <>
{props.channel.name}
); }; const ChannelList: Component = () => { // track collapsed state per section via a map keyed by section id const [collapsedSections, setCollapsedSections] = createSignal< Record >({}); const [activeId, setActiveId] = createSignal(null); const [droppableId, setDroppableId] = createSignal(null); // channels without a category, grouped by kind const uncategorizedText = () => channels().filter((c) => !c.category_id && c.kind === "Text"); const uncategorizedVoice = () => channels().filter((c) => !c.category_id && c.kind === "Voice"); // channels belonging to a specific category const channelsForCategory = (catId: string) => channels().filter((c) => c.category_id === catId); const community = () => activeCommunity(); const toggleSection = (id: string) => { setCollapsedSections((prev) => ({ ...prev, [id]: !prev[id] })); }; const isSectionCollapsed = (id: string) => !!collapsedSections()[id]; const handleDragStart = ({ draggable }: any) => { setActiveId(draggable.id as string); setDroppableId(null); }; const handleDragOver = ({ droppable }: any) => { if (droppable) { setDroppableId(droppable.id as string); } }; const handleDragEnd = ({ draggable, droppable }: any) => { setActiveId(null); setDroppableId(null); if (!droppable) return; const fromId = draggable.id as string; const toId = droppable.id as string; const allChannels = channels(); const fromChannel = allChannels.find((c) => c.id === fromId); if (!fromChannel) return; const toChannel = allChannels.find((c) => c.id === toId); // only allow dragging within the same category and kind if (fromChannel.kind !== toChannel?.kind) return; if (fromChannel.category_id !== toChannel?.category_id) return; // get channels in the same group const groupChannels = allChannels.filter( (c) => c.kind === fromChannel.kind && c.category_id === fromChannel.category_id, ); const ids = groupChannels.map((c) => c.id); const fromIndex = ids.indexOf(fromId); const toIndex = ids.indexOf(toId); if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return; const newOrder = [...ids]; newOrder.splice(fromIndex, 1); newOrder.splice(toIndex, 0, fromId); // update local state with new positions const otherChannels = allChannels.filter( (c) => c.kind !== fromChannel.kind || c.category_id !== fromChannel.category_id, ); const reorderedChannels = newOrder.map((id, index) => { const channel = groupChannels.find((c) => c.id === id)!; return { ...channel, position: index }; }); setChannels([...otherChannels, ...reorderedChannels]); reorderChannels(newOrder); }; // compute ghost placement for a given channel in its list const getGhost = ( channelId: string, channelList: ChannelMeta[], ): GhostInfo | null => { const active = activeId(); const droppable = droppableId(); if (!active || !droppable || active === droppable) return null; if (channelId !== droppable) return null; const draggedChannel = channelList.find((c) => c.id === active); if (!draggedChannel) return null; const ids = channelList.map((c) => c.id); const fromIndex = ids.indexOf(active); const toIndex = ids.indexOf(droppable); if (fromIndex === -1 || toIndex === -1) return null; return { channel: draggedChannel, position: fromIndex < toIndex ? "below" : "above", }; }; // renders a collapsible channel section header const SectionHeader: Component<{ sectionId: string; label: string; showAdd?: boolean; }> = (props) => (
); // participants currently in a voice channel, including the local user const voiceChannelParticipants = () => { const localUser = identity(); const remote = voiceParticipants().filter( (p) => p.peer_id !== localUser?.peer_id, ); const all = []; if (localUser) { all.push({ peer_id: localUser.peer_id, display_name: localUser.display_name, is_local: true, muted: localMediaState().muted, }); } for (const p of remote) { all.push({ peer_id: p.peer_id, display_name: p.display_name, is_local: false, muted: p.media_state.muted, }); } return all; }; const handleChannelClick = (channel: ChannelMeta) => { if (channel.kind === "Voice") { // clicking a voice channel joins it (or switches to it) const currentVoice = voiceChannelId(); if (currentVoice === channel.id) return; joinVoice(channel.community_id, channel.id); } else { setActiveChannel(channel.id); } }; // renders a list of channels with drag-and-drop support const ChannelGroup: Component<{ sectionId: string; channelList: ChannelMeta[]; }> = (props) => { const ids = () => props.channelList.map((c) => c.id); return ( {(channel) => ( <> handleChannelClick(channel)} ghost={getGhost(channel.id, props.channelList)} /> {/* discord-style participant list under active voice channels */}
{(participant) => (
{participant.display_name}
)}
)}
); }; const header = (
dusk } > {community()!.name}
); const body = (
{/* uncategorized text channels */} 0}> {/* uncategorized voice channels */} 0}>
{/* category sections */} {(cat) => { const catChannels = () => channelsForCategory(cat.id); return ( 0 || community()}>
); }}
{/* create channel / create category buttons when no channels exist yet */}
{/* create category button */}
); // compact voice connection panel rendered above the user footer const voicePanel = () => { if (!isInVoice()) return null; const channelName = () => { const id = voiceChannelId(); return channels().find((c) => c.id === id)?.name ?? "voice"; }; return (
voice connected {channelName()}
); }; return ( openModal("settings")} > {body} ); }; export default ChannelList;