diff --git a/desktop/src/features/channels/ui/EphemeralChannelBadge.tsx b/desktop/src/features/channels/ui/EphemeralChannelBadge.tsx index c8af40f31..286c6f11a 100644 --- a/desktop/src/features/channels/ui/EphemeralChannelBadge.tsx +++ b/desktop/src/features/channels/ui/EphemeralChannelBadge.tsx @@ -26,10 +26,10 @@ export function EphemeralChannelBadge({ void; onCreateClick: () => void; }) { return ( -
+
+ + +
+ {!isCollapsed ? ( + + {items.length > 0 ? ( + + {items.map((channel) => ( + + + + ))} + + ) : null} + + ) : null} ); } @@ -273,6 +328,23 @@ export function AppSidebar({ const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const [createDialogKind, setCreateDialogKind] = React.useState(null); + const [collapsedGroups, setCollapsedGroups] = React.useState< + Record + >({ + channels: false, + forums: false, + directMessages: false, + }); + + const toggleCollapsedGroup = React.useCallback( + (group: CollapsibleSidebarGroup) => { + setCollapsedGroups((current) => ({ + ...current, + [group]: !current[group], + })); + }, + [], + ); const streamChannels = React.useMemo( () => channels.filter((channel) => channel.channelType === "stream"), @@ -341,6 +413,7 @@ export function AppSidebar({ return ( setCreateDialogKind("stream")} onSelectChannel={onSelectChannel} + onToggleCollapsed={() => toggleCollapsedGroup("channels")} selectedChannelId={selectedChannelId} title="Channels" unreadChannelIds={unreadChannelIds} @@ -472,12 +547,14 @@ export function AppSidebar({ browseAriaLabel="Browse forums" browseTestId="browse-forums" createAriaLabel="Create a forum" + isCollapsed={collapsedGroups.forums} isActiveChannel={selectedView === "channel"} items={forumChannels} listTestId="forum-list" onBrowse={onOpenBrowseForums} onCreateClick={() => setCreateDialogKind("forum")} onSelectChannel={onSelectChannel} + onToggleCollapsed={() => toggleCollapsedGroup("forums")} selectedChannelId={selectedChannelId} title="Forums" unreadChannelIds={unreadChannelIds} @@ -487,7 +564,10 @@ export function AppSidebar({ { setIsNewDmOpen(true); @@ -498,11 +578,13 @@ export function AppSidebar({ } dmParticipantsByChannelId={dmParticipantsByChannelId} + isCollapsed={collapsedGroups.directMessages} isActiveChannel={selectedView === "channel"} items={directMessages} channelLabels={dmChannelLabels} onHideDm={onHideDm} onSelectChannel={onSelectChannel} + onToggleCollapsed={() => toggleCollapsedGroup("directMessages")} presenceByChannelId={dmPresenceByChannelId} selectedChannelId={selectedChannelId} testId="dm-list" diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index ce920195a..789cffe12 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -1,5 +1,5 @@ import type * as React from "react"; -import { CircleDot, FileText, Hash, Lock, X } from "lucide-react"; +import { ChevronDown, CircleDot, FileText, Hash, Lock, X } from "lucide-react"; import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge"; @@ -18,6 +18,11 @@ import { import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +const SECTION_LABEL_BUTTON_CLASS = + "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; +const SECTION_LABEL_CHEVRON_CLASS = + "h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground"; + export type SidebarDmParticipant = { avatarUrl: string | null; label: string; @@ -187,6 +192,7 @@ export function SidebarSection({ emptyState, items, channelLabels, + isCollapsed, isActiveChannel, presenceByChannelId, selectedChannelId, @@ -195,12 +201,14 @@ export function SidebarSection({ unreadChannelIds, onHideDm, onSelectChannel, + onToggleCollapsed, }: { action?: React.ReactNode; dmParticipantsByChannelId?: Record; emptyState?: React.ReactNode; items: Channel[]; channelLabels?: Record; + isCollapsed?: boolean; isActiveChannel: boolean; presenceByChannelId?: Record; selectedChannelId: string | null; @@ -209,60 +217,91 @@ export function SidebarSection({ unreadChannelIds: Set; onHideDm?: (channelId: string) => void; onSelectChannel: (channelId: string) => void; + onToggleCollapsed?: () => void; }) { if (items.length === 0 && !action && !emptyState) { return null; } + const contentId = `sidebar-${testId}`; + const canToggle = Boolean(onToggleCollapsed); + return ( - {title} - {action} - - {items.length > 0 ? ( - - {items.map((channel) => ( - - - {channel.channelType === "dm" && - unreadChannelIds.has(channel.id) && - !(isActiveChannel && selectedChannelId === channel.id) ? ( -
+ {!isCollapsed ? ( + + {items.length > 0 ? ( + + {items.map((channel) => ( + + - ) : null} - {channel.channelType === "dm" && onHideDm ? ( - onHideDm(channel.id)} - showOnHover - > - - - ) : null} - - ))} - - ) : emptyState ? ( -
- {emptyState} -
- ) : null} -
+ {channel.channelType === "dm" && + unreadChannelIds.has(channel.id) && + !(isActiveChannel && selectedChannelId === channel.id) ? ( +