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