feat(desktop): add user-defined channel sections to sidebar (#789)

This commit is contained in:
Will Pfleger
2026-06-01 12:42:19 -07:00
committed by GitHub
parent d810608d85
commit 247ac52391
11 changed files with 1776 additions and 251 deletions
+3
View File
@@ -21,6 +21,9 @@
"tauri:build": "tauri build"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
+3 -2
View File
@@ -39,14 +39,15 @@ const overrides = new Map([
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/channels/ui/ChannelPane.tsx", 540], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + thread follow props passthrough
["src/features/channels/ui/ChannelScreen.tsx", 580], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + thread follow wiring from AppShell context
["src/features/channels/useUnreadChannels.ts", 715], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks
["src/features/channels/useUnreadChannels.ts", 717], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks + markChannelRead latestByChannelRef fallback chain (matches markChannelUnread)
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/lib/useRichTextEditor.ts", 560], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up).
["src/features/messages/ui/MessageComposer.tsx", 820], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/features/sidebar/ui/AppSidebar.tsx", 830], // channels + forums creation forms + Pulse nav + channel sections state/dialogs + SidebarDndContext wrapper + sectionIds memo for DnD section reorder + controlled create-channel dialog for ⌘⇧N shortcut
["src/features/sidebar/ui/CustomChannelSection.tsx", 615], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu + per-section mark-all-read + DnD wrappers (SortableSectionShell, DraggableChannelRow, DroppableSectionBody, DroppableUngroupedBody) + draggable prop on ChannelGroupSection
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
@@ -101,6 +101,7 @@ export class ReadStateManager {
}
markContextRead(contextId: string, unixTimestamp: number): void {
this.forcedContexts.delete(contextId);
this.advanceContext(contextId, unixTimestamp, { publishable: true });
}
@@ -296,7 +296,10 @@ export function useUnreadChannels(
const markChannelRead = React.useCallback(
(channelId: string, readAt: string | null | undefined) => {
const unixSeconds = toUnixSeconds(readAt);
const unixSeconds =
toUnixSeconds(readAt) ??
latestByChannelRef.current.get(channelId) ??
null;
if (unixSeconds === null) return;
// Reading clears any prior manual mark-unread.
if (forcedUnreadRef.current.delete(channelId)) {
@@ -0,0 +1,91 @@
const STORAGE_KEY_PREFIX = "sprout-channel-sections.v1";
export type ChannelSection = {
id: string;
name: string;
order: number;
};
export type ChannelSectionStore = {
version: 1;
sections: ChannelSection[];
assignments: Record<string, string>;
};
export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({
version: 1,
sections: [],
assignments: {},
});
export function storageKey(pubkey: string): string {
return `${STORAGE_KEY_PREFIX}:${pubkey}`;
}
export function stripOrphanedAssignments(
store: ChannelSectionStore,
): ChannelSectionStore {
const sectionIds = new Set(store.sections.map((s) => s.id));
const cleaned = Object.fromEntries(
Object.entries(store.assignments).filter(([, sid]) => sectionIds.has(sid)),
);
if (Object.keys(cleaned).length === Object.keys(store.assignments).length)
return store;
return { ...store, assignments: cleaned };
}
export function parseChannelSectionPayload(
json: unknown,
): ChannelSectionStore | null {
if (typeof json !== "object" || json === null) return null;
const obj = json as Record<string, unknown>;
const sections: ChannelSection[] = Array.isArray(obj.sections)
? obj.sections.filter(
(entry: unknown): entry is ChannelSection =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as Record<string, unknown>).id === "string" &&
typeof (entry as Record<string, unknown>).name === "string" &&
typeof (entry as Record<string, unknown>).order === "number",
)
: [];
const assignments: Record<string, string> =
typeof obj.assignments === "object" &&
obj.assignments !== null &&
!Array.isArray(obj.assignments)
? Object.fromEntries(
Object.entries(obj.assignments as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
: {};
return stripOrphanedAssignments({ version: 1, sections, assignments });
}
export function readChannelSectionsStore(pubkey: string): ChannelSectionStore {
try {
const raw = window.localStorage.getItem(storageKey(pubkey));
if (!raw) {
return DEFAULT_STORE;
}
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
return DEFAULT_STORE;
}
return parseChannelSectionPayload(parsed) ?? DEFAULT_STORE;
} catch {
return DEFAULT_STORE;
}
}
export function writeChannelSectionsStore(
pubkey: string,
store: ChannelSectionStore,
): boolean {
try {
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store));
return true;
} catch {
return false;
}
}
@@ -0,0 +1,279 @@
import * as React from "react";
import {
DEFAULT_STORE,
readChannelSectionsStore,
storageKey,
writeChannelSectionsStore,
} from "./channelSectionsStorage";
export type { ChannelSection } from "./channelSectionsStorage";
import type {
ChannelSection,
ChannelSectionStore,
} from "./channelSectionsStorage";
export function useChannelSections(pubkey: string | undefined): {
sections: ChannelSection[];
assignments: Record<string, string>;
createSection: (name: string) => ChannelSection | null;
renameSection: (sectionId: string, newName: string) => void;
deleteSection: (sectionId: string) => void;
moveSectionUp: (sectionId: string) => void;
moveSectionDown: (sectionId: string) => void;
reorderSections: (orderedIds: string[]) => void;
assignChannel: (channelId: string, sectionId: string) => void;
unassignChannel: (channelId: string) => void;
} {
const [store, setStore] = React.useState<ChannelSectionStore>(() => {
if (!pubkey) {
return DEFAULT_STORE;
}
return readChannelSectionsStore(pubkey);
});
React.useEffect(() => {
if (!pubkey) {
setStore(DEFAULT_STORE);
return;
}
setStore(readChannelSectionsStore(pubkey));
}, [pubkey]);
React.useEffect(() => {
if (!pubkey) {
return;
}
const key = storageKey(pubkey);
const handler = (e: StorageEvent) => {
if (e.key !== key) {
return;
}
setStore(readChannelSectionsStore(pubkey));
};
window.addEventListener("storage", handler);
return () => {
window.removeEventListener("storage", handler);
};
}, [pubkey]);
const sections = React.useMemo<ChannelSection[]>(
() => store.sections.slice().sort((a, b) => a.order - b.order),
[store.sections],
);
const createSection = React.useCallback(
(name: string): ChannelSection | null => {
if (!pubkey) {
return null;
}
let created: ChannelSection | null = null;
setStore((prev) => {
const maxOrder =
prev.sections.length > 0
? Math.max(...prev.sections.map((s) => s.order))
: -1;
const section: ChannelSection = {
id: crypto.randomUUID(),
name,
order: maxOrder + 1,
};
const next: ChannelSectionStore = {
...prev,
sections: [...prev.sections, section],
};
if (!writeChannelSectionsStore(pubkey, next)) {
return prev;
}
created = section;
return next;
});
return created;
},
[pubkey],
);
const renameSection = React.useCallback(
(sectionId: string, newName: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const next: ChannelSectionStore = {
...prev,
sections: prev.sections.map((s) =>
s.id === sectionId ? { ...s, name: newName } : s,
),
};
if (!writeChannelSectionsStore(pubkey, next)) {
return prev;
}
return next;
});
},
[pubkey],
);
const deleteSection = React.useCallback(
(sectionId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const assignments = { ...prev.assignments };
for (const channelId of Object.keys(assignments)) {
if (assignments[channelId] === sectionId) {
delete assignments[channelId];
}
}
const next: ChannelSectionStore = {
...prev,
sections: prev.sections.filter((s) => s.id !== sectionId),
assignments,
};
if (!writeChannelSectionsStore(pubkey, next)) {
return prev;
}
return next;
});
},
[pubkey],
);
const moveSectionUp = React.useCallback(
(sectionId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const target = prev.sections.find((s) => s.id === sectionId);
if (!target) {
return prev;
}
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
const idx = sorted.findIndex((s) => s.id === sectionId);
if (idx <= 0) {
return prev;
}
const neighbor = sorted[idx - 1];
const sections = prev.sections.map((s) => {
if (s.id === target.id) {
return { ...s, order: neighbor.order };
}
if (s.id === neighbor.id) {
return { ...s, order: target.order };
}
return s;
});
const next: ChannelSectionStore = { ...prev, sections };
if (!writeChannelSectionsStore(pubkey, next)) {
return prev;
}
return next;
});
},
[pubkey],
);
const moveSectionDown = React.useCallback(
(sectionId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const target = prev.sections.find((s) => s.id === sectionId);
if (!target) {
return prev;
}
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
const idx = sorted.findIndex((s) => s.id === sectionId);
if (idx < 0 || idx >= sorted.length - 1) {
return prev;
}
const neighbor = sorted[idx + 1];
const sections = prev.sections.map((s) => {
if (s.id === target.id) {
return { ...s, order: neighbor.order };
}
if (s.id === neighbor.id) {
return { ...s, order: target.order };
}
return s;
});
const next: ChannelSectionStore = { ...prev, sections };
if (!writeChannelSectionsStore(pubkey, next)) {
return prev;
}
return next;
});
},
[pubkey],
);
const reorderSections = React.useCallback(
(orderedIds: string[]) => {
if (!pubkey) return;
setStore((prev) => {
const sections = prev.sections.map((s) => {
const newOrder = orderedIds.indexOf(s.id);
return newOrder === -1 ? s : { ...s, order: newOrder };
});
const next: ChannelSectionStore = { ...prev, sections };
if (!writeChannelSectionsStore(pubkey, next)) return prev;
return next;
});
},
[pubkey],
);
const assignChannel = React.useCallback(
(channelId: string, sectionId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const next: ChannelSectionStore = {
...prev,
assignments: { ...prev.assignments, [channelId]: sectionId },
};
if (!writeChannelSectionsStore(pubkey, next)) {
return prev;
}
return next;
});
},
[pubkey],
);
const unassignChannel = React.useCallback(
(channelId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const assignments = { ...prev.assignments };
delete assignments[channelId];
const next: ChannelSectionStore = { ...prev, assignments };
if (!writeChannelSectionsStore(pubkey, next)) {
return prev;
}
return next;
});
},
[pubkey],
);
return {
sections,
assignments: store.assignments,
createSection,
renameSection,
deleteSection,
moveSectionUp,
moveSectionDown,
reorderSections,
assignChannel,
unassignChannel,
};
}
+218 -246
View File
@@ -4,18 +4,13 @@ import {
ArrowDown,
ArrowUp,
Bot,
CheckCheck,
CheckCircle2,
ChevronDown,
CircleDot,
FolderGit2,
Home,
PenSquare,
Plus,
Search,
Zap,
} from "lucide-react";
import * as React from "react";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import type { Workspace } from "@/features/workspaces/types";
@@ -26,14 +21,25 @@ import { getPresenceLabel } from "@/features/presence/lib/presence";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
import {
useChannelSections,
type ChannelSection,
} from "@/features/sidebar/lib/useChannelSections";
import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata";
import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock";
import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
import {
ChannelMenuButton,
SidebarSection,
} from "@/features/sidebar/ui/SidebarSection";
CreateSectionDialog,
DeleteSectionAlertDialog,
RenameSectionDialog,
} from "@/features/sidebar/ui/ChannelSectionDialogs";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
import {
ChannelGroupSection,
CustomChannelSection,
SECTION_ACTION_VISIBILITY_CLASS,
} from "@/features/sidebar/ui/CustomChannelSection";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import type {
@@ -44,12 +50,6 @@ import type {
UserStatus,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import {
Sidebar,
SidebarContent,
@@ -66,19 +66,6 @@ import {
SidebarMenuSkeleton,
} from "@/shared/ui/sidebar";
// ---------------------------------------------------------------------------
// Shared styles
// ---------------------------------------------------------------------------
const SECTION_ICON_BUTTON_CLASS =
"flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground";
const SECTION_ACTION_VISIBILITY_CLASS =
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
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";
type CollapsibleSidebarGroup = "channels" | "forums" | "directMessages";
// ---------------------------------------------------------------------------
@@ -165,202 +152,6 @@ type AppSidebarProps = {
onCreateChannelOpenChange?: (open: boolean) => void;
};
// ---------------------------------------------------------------------------
// SectionHeaderActions — browse + create icon buttons for section headers
// ---------------------------------------------------------------------------
function SectionHeaderActions({
browseAriaLabel,
browseTestId,
className,
createAriaLabel,
hasUnread,
onBrowse,
onCreateClick,
onMarkAllRead,
}: {
browseAriaLabel: string;
browseTestId?: string;
className?: string;
createAriaLabel: string;
hasUnread?: boolean;
onBrowse: () => void;
onCreateClick: () => void;
onMarkAllRead?: () => void;
}) {
return (
<div
className={cn(
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
className,
)}
>
{hasUnread && onMarkAllRead ? (
<button
aria-label="Mark all as read"
className={SECTION_ICON_BUTTON_CLASS}
onClick={onMarkAllRead}
title="Mark all as read"
type="button"
>
<CheckCheck className="h-3.5 w-3.5" />
</button>
) : null}
<button
aria-label={browseAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
data-testid={browseTestId}
onClick={onBrowse}
type="button"
>
<Search className="h-3.5 w-3.5" />
</button>
<button
aria-label={createAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
onClick={onCreateClick}
type="button"
>
<Plus className="h-4 w-4" />
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// ChannelGroupSection — unified Channels / Forums section (no inline form)
// ---------------------------------------------------------------------------
function ChannelGroupSection({
browseAriaLabel,
browseTestId,
createAriaLabel,
groupClassName,
hasUnread,
isCollapsed,
isActiveChannel,
items,
listTestId,
onBrowse,
onCreateClick,
onMarkAllRead,
onMarkChannelRead,
onMarkChannelUnread,
onSelectChannel,
onToggleCollapsed,
selectedChannelId,
title,
unreadChannelIds,
}: {
browseAriaLabel: string;
browseTestId?: string;
createAriaLabel: string;
groupClassName?: string;
isCollapsed: boolean;
isActiveChannel: boolean;
items: Channel[];
listTestId: string;
onBrowse: () => void;
onCreateClick: () => void;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed: () => void;
selectedChannelId: string | null;
title: string;
unreadChannelIds: Set<string>;
hasUnread?: boolean;
onMarkAllRead?: () => void;
}) {
const contentId = `sidebar-${listTestId}`;
return (
<SidebarGroup className={groupClassName}>
<div className="group/sidebar-section relative">
<SidebarGroupLabel asChild>
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
onClick={onToggleCollapsed}
type="button"
>
<span>{title}</span>
<ChevronDown
aria-hidden="true"
className={cn(
SECTION_LABEL_CHEVRON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
/>
</button>
</SidebarGroupLabel>
<SectionHeaderActions
browseAriaLabel={browseAriaLabel}
browseTestId={browseTestId}
className={SECTION_ACTION_VISIBILITY_CLASS}
createAriaLabel={createAriaLabel}
hasUnread={hasUnread}
onBrowse={onBrowse}
onCreateClick={onCreateClick}
onMarkAllRead={onMarkAllRead}
/>
</div>
{!isCollapsed ? (
<SidebarGroupContent id={contentId}>
{items.length > 0 ? (
<SidebarMenu data-testid={listTestId}>
{items.map((channel) => (
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
</ContextMenuTrigger>
<ContextMenuContent>
{unreadChannelIds.has(channel.id) ? (
<ContextMenuItem
onClick={() =>
onMarkChannelRead(channel.id, channel.lastMessageAt)
}
>
<CheckCircle2 className="h-4 w-4" />
Mark as read
</ContextMenuItem>
) : (
<ContextMenuItem
onClick={() =>
onMarkChannelUnread(channel.id, channel.lastMessageAt)
}
>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
))}
</SidebarMenu>
) : null}
</SidebarGroupContent>
) : null}
</SidebarGroup>
);
}
// ---------------------------------------------------------------------------
// AppSidebar
// ---------------------------------------------------------------------------
@@ -453,10 +244,88 @@ export function AppSidebar({
[],
);
const [collapsedSections, setCollapsedSections] = React.useState<
Record<string, boolean>
>({});
const toggleCollapsedSection = React.useCallback((sectionId: string) => {
setCollapsedSections((current) => ({
...current,
[sectionId]: !current[sectionId],
}));
}, []);
const {
sections: channelSections,
assignments: channelAssignments,
createSection,
renameSection,
deleteSection,
moveSectionUp,
moveSectionDown,
reorderSections,
assignChannel,
unassignChannel,
} = useChannelSections(currentPubkey);
const [createSectionState, setCreateSectionState] = React.useState<{
open: boolean;
pendingChannelId: string | null;
}>({ open: false, pendingChannelId: null });
const [renameSectionTarget, setRenameSectionTarget] =
React.useState<ChannelSection | null>(null);
const [deleteSectionTarget, setDeleteSectionTarget] =
React.useState<ChannelSection | null>(null);
const sectionIds = React.useMemo(
() => channelSections.map((s) => s.id),
[channelSections],
);
const streamChannels = React.useMemo(
() => channels.filter((channel) => channel.channelType === "stream"),
[channels],
);
const sectionBuckets = React.useMemo(() => {
const bySection: Record<string, Channel[]> = {};
const unassigned: Channel[] = [];
const sectionIds = new Set(channelSections.map((s) => s.id));
for (const channel of streamChannels) {
const sectionId = channelAssignments[channel.id];
if (sectionId && sectionIds.has(sectionId)) {
if (!bySection[sectionId]) {
bySection[sectionId] = [];
}
bySection[sectionId].push(channel);
} else {
unassigned.push(channel);
}
}
return { bySection, unassigned };
}, [streamChannels, channelSections, channelAssignments]);
const handleCreateSectionForChannel = React.useCallback(
(channelId: string) => {
setCreateSectionState({ open: true, pendingChannelId: channelId });
},
[],
);
const handleCreateSectionConfirm = React.useCallback(
(name: string) => {
const section = createSection(name);
if (!section) {
return;
}
if (createSectionState.pendingChannelId) {
assignChannel(createSectionState.pendingChannelId, section.id);
}
setCreateSectionState({ open: false, pendingChannelId: null });
},
[createSection, assignChannel, createSectionState.pendingChannelId],
);
const forumChannels = React.useMemo(
() => channels.filter((channel) => channel.channelType === "forum"),
[channels],
@@ -641,27 +510,82 @@ export function AppSidebar({
{!isLoading ? (
<>
<ChannelGroupSection
browseAriaLabel="Browse channels"
browseTestId="browse-channels"
createAriaLabel="Create a channel"
groupClassName="pt-1"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
items={streamChannels}
listTestId="stream-list"
onBrowse={onOpenBrowseChannels}
onCreateClick={() => setCreateDialogKind("stream")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
title="Channels"
unreadChannelIds={unreadChannelIds}
/>
<SidebarDndContext
channels={channels}
sections={channelSections}
sectionIds={sectionIds}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onReorderSections={reorderSections}
>
{channelSections.map((section, idx) => (
<CustomChannelSection
key={section.id}
section={section}
channels={sectionBuckets.bySection[section.id] ?? []}
hasUnread={
sectionBuckets.bySection[section.id]?.some((c) =>
unreadChannelIds.has(c.id),
) ?? false
}
isCollapsed={collapsedSections[section.id] ?? false}
isActiveChannel={selectedView === "channel"}
selectedChannelId={selectedChannelId}
unreadChannelIds={unreadChannelIds}
sections={channelSections}
assignments={channelAssignments}
isFirst={idx === 0}
isLast={idx === channelSections.length - 1}
onToggleCollapsed={() => toggleCollapsedSection(section.id)}
onSelectChannel={onSelectChannel}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onMarkSectionRead={() => {
for (const channel of sectionBuckets.bySection[
section.id
] ?? []) {
onMarkChannelRead(channel.id, channel.lastMessageAt);
}
}}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
onRenameSection={() => setRenameSectionTarget(section)}
onDeleteSection={() => setDeleteSectionTarget(section)}
onMoveSectionUp={() => moveSectionUp(section.id)}
onMoveSectionDown={() => moveSectionDown(section.id)}
/>
))}
<ChannelGroupSection
browseAriaLabel="Browse channels"
browseTestId="browse-channels"
createAriaLabel="Create a channel"
draggable
groupClassName={
channelSections.length > 0 ? undefined : "pt-1"
}
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
items={sectionBuckets.unassigned}
listTestId="stream-list"
onBrowse={onOpenBrowseChannels}
onCreateClick={() => setCreateDialogKind("stream")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
title="Channels"
unreadChannelIds={unreadChannelIds}
sections={channelSections}
assignments={channelAssignments}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
/>
</SidebarDndContext>
<ChannelGroupSection
browseAriaLabel="Browse forums"
browseTestId="browse-forums"
@@ -848,6 +772,54 @@ export function AppSidebar({
onSubmit={onAddWorkspace}
open={isAddWorkspaceOpen ?? false}
/>
<CreateSectionDialog
open={createSectionState.open}
onOpenChange={(open) => {
if (!open) {
setCreateSectionState({ open: false, pendingChannelId: null });
}
}}
onConfirm={handleCreateSectionConfirm}
/>
<RenameSectionDialog
open={renameSectionTarget !== null}
onOpenChange={(open) => {
if (!open) setRenameSectionTarget(null);
}}
sectionName={renameSectionTarget?.name ?? ""}
onConfirm={(newName) => {
if (renameSectionTarget) {
renameSection(renameSectionTarget.id, newName);
}
setRenameSectionTarget(null);
}}
/>
<DeleteSectionAlertDialog
open={deleteSectionTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteSectionTarget(null);
}}
sectionName={deleteSectionTarget?.name ?? ""}
channelCount={
deleteSectionTarget
? (sectionBuckets.bySection[deleteSectionTarget.id]?.length ?? 0)
: 0
}
onConfirm={() => {
if (deleteSectionTarget) {
deleteSection(deleteSectionTarget.id);
setCollapsedSections((prev) => {
const next = { ...prev };
delete next[deleteSectionTarget.id];
return next;
});
}
setDeleteSectionTarget(null);
}}
/>
</Sidebar>
);
}
@@ -0,0 +1,210 @@
import * as React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
// ---------------------------------------------------------------------------
// SectionNameDialog (internal)
// ---------------------------------------------------------------------------
type SectionNameDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
initialValue: string;
confirmLabel: string;
isConfirmDisabled: (trimmed: string) => boolean;
onConfirm: (name: string) => void;
};
function SectionNameDialog({
open,
onOpenChange,
title,
description,
initialValue,
confirmLabel,
isConfirmDisabled,
onConfirm,
}: SectionNameDialogProps) {
const [name, setName] = React.useState(initialValue);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (!open) return;
setName(initialValue);
// Small delay to let dialog animation start before focusing
const timerId = globalThis.setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => globalThis.clearTimeout(timerId);
}, [open, initialValue]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = name.trim();
if (isConfirmDisabled(trimmed)) return;
onConfirm(trimmed);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
onChange={(event) => setName(event.target.value)}
placeholder="Section name"
ref={inputRef}
spellCheck={false}
value={name}
/>
<div className="flex justify-end gap-2 mt-4">
<DialogClose asChild>
<Button variant="ghost" type="button">
Cancel
</Button>
</DialogClose>
<Button type="submit" disabled={isConfirmDisabled(name.trim())}>
{confirmLabel}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// CreateSectionDialog
// ---------------------------------------------------------------------------
export type CreateSectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
};
export function CreateSectionDialog({
open,
onOpenChange,
onConfirm,
}: CreateSectionDialogProps) {
return (
<SectionNameDialog
open={open}
onOpenChange={onOpenChange}
title="Create section"
description="Sections let you group related channels in the sidebar."
initialValue=""
confirmLabel="Create"
isConfirmDisabled={(trimmed) => trimmed.length === 0}
onConfirm={onConfirm}
/>
);
}
// ---------------------------------------------------------------------------
// RenameSectionDialog
// ---------------------------------------------------------------------------
export type RenameSectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sectionName: string;
onConfirm: (newName: string) => void;
};
export function RenameSectionDialog({
open,
onOpenChange,
sectionName,
onConfirm,
}: RenameSectionDialogProps) {
return (
<SectionNameDialog
open={open}
onOpenChange={onOpenChange}
title="Rename section"
description="Enter a new name for this section."
initialValue={sectionName}
confirmLabel="Rename"
isConfirmDisabled={(trimmed) =>
trimmed.length === 0 || trimmed === sectionName
}
onConfirm={onConfirm}
/>
);
}
// ---------------------------------------------------------------------------
// DeleteSectionAlertDialog
// ---------------------------------------------------------------------------
export type DeleteSectionAlertDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sectionName: string;
channelCount: number;
onConfirm: () => void;
};
export function DeleteSectionAlertDialog({
open,
onOpenChange,
sectionName,
channelCount,
onConfirm,
}: DeleteSectionAlertDialogProps) {
const channelLabel =
channelCount === 1 ? "1 channel" : `${channelCount} channels`;
const description =
channelCount === 0
? `Delete section "${sectionName}"? It has no channels.`
: `Delete section "${sectionName}"? Its ${channelLabel} will move back to the default Channels group.`;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete section</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,611 @@
import {
ArrowDown,
ArrowUp,
Check,
CheckCheck,
CheckCircle2,
ChevronDown,
CircleDot,
GripVertical,
Pencil,
Plus,
Search,
Trash2,
} from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection";
import {
DraggableChannelRow,
DroppableSectionBody,
DroppableUngroupedBody,
SortableSectionShell,
} from "@/features/sidebar/ui/SidebarDnd";
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
// ---------------------------------------------------------------------------
// Shared styles
// ---------------------------------------------------------------------------
const SECTION_ICON_BUTTON_CLASS =
"flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground";
export const SECTION_ACTION_VISIBILITY_CLASS =
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
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";
// ---------------------------------------------------------------------------
// MoveToSectionSubmenu — internal helper
// ---------------------------------------------------------------------------
function MoveToSectionSubmenu({
channelId,
sections,
assignments,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
}: {
channelId: string;
sections: ChannelSection[];
assignments: Record<string, string>;
onAssignChannel: (channelId: string, sectionId: string) => void;
onUnassignChannel: (channelId: string) => void;
onCreateSectionForChannel: (channelId: string) => void;
}) {
const currentSectionId = assignments[channelId];
return (
<ContextMenuSub>
<ContextMenuSubTrigger>Move to section</ContextMenuSubTrigger>
<ContextMenuSubContent>
{sections.map((section) => (
<ContextMenuItem
key={section.id}
onClick={() => onAssignChannel(channelId, section.id)}
>
{currentSectionId === section.id ? (
<Check className="h-4 w-4" />
) : (
<span className="h-4 w-4" />
)}
{section.name}
</ContextMenuItem>
))}
{sections.length > 0 ? <ContextMenuSeparator /> : null}
<ContextMenuItem onClick={() => onCreateSectionForChannel(channelId)}>
<Plus className="h-4 w-4" />
New section...
</ContextMenuItem>
{currentSectionId ? (
<ContextMenuItem onClick={() => onUnassignChannel(channelId)}>
Remove from section
</ContextMenuItem>
) : null}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
// ---------------------------------------------------------------------------
// ChannelContextMenuItems — shared context menu items for channel rows
// ---------------------------------------------------------------------------
export function ChannelContextMenuItems({
channel,
hasUnread,
sections,
assignments,
onMarkChannelRead,
onMarkChannelUnread,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
}: {
channel: Channel;
hasUnread: boolean;
sections?: ChannelSection[];
assignments?: Record<string, string>;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onAssignChannel?: (channelId: string, sectionId: string) => void;
onUnassignChannel?: (channelId: string) => void;
onCreateSectionForChannel?: (channelId: string) => void;
}) {
return (
<>
{hasUnread ? (
<ContextMenuItem
onClick={() => onMarkChannelRead(channel.id, channel.lastMessageAt)}
>
<CheckCircle2 className="h-4 w-4" />
Mark as read
</ContextMenuItem>
) : (
<ContextMenuItem
onClick={() => onMarkChannelUnread(channel.id, channel.lastMessageAt)}
>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
)}
{sections &&
assignments &&
onAssignChannel &&
onUnassignChannel &&
onCreateSectionForChannel ? (
<>
<ContextMenuSeparator />
<MoveToSectionSubmenu
channelId={channel.id}
sections={sections}
assignments={assignments}
onAssignChannel={onAssignChannel}
onUnassignChannel={onUnassignChannel}
onCreateSectionForChannel={onCreateSectionForChannel}
/>
</>
) : null}
</>
);
}
// ---------------------------------------------------------------------------
// SectionHeaderActions — browse + create icon buttons for section headers
// ---------------------------------------------------------------------------
function SectionHeaderActions({
browseAriaLabel,
browseTestId,
className,
createAriaLabel,
hasUnread,
onBrowse,
onCreateClick,
onMarkAllRead,
}: {
browseAriaLabel: string;
browseTestId?: string;
className?: string;
createAriaLabel: string;
hasUnread?: boolean;
onBrowse: () => void;
onCreateClick: () => void;
onMarkAllRead?: () => void;
}) {
return (
<div
className={cn(
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
className,
)}
>
{hasUnread && onMarkAllRead ? (
<button
aria-label="Mark all as read"
className={SECTION_ICON_BUTTON_CLASS}
onClick={onMarkAllRead}
title="Mark all as read"
type="button"
>
<CheckCheck className="h-3.5 w-3.5" />
</button>
) : null}
<button
aria-label={browseAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
data-testid={browseTestId}
onClick={onBrowse}
type="button"
>
<Search className="h-3.5 w-3.5" />
</button>
<button
aria-label={createAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
onClick={onCreateClick}
type="button"
>
<Plus className="h-4 w-4" />
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// ChannelGroupSection — unified Channels / Forums section (no inline form)
// ---------------------------------------------------------------------------
export function ChannelGroupSection({
browseAriaLabel,
browseTestId,
createAriaLabel,
draggable,
groupClassName,
hasUnread,
isCollapsed,
isActiveChannel,
items,
listTestId,
onBrowse,
onCreateClick,
onMarkAllRead,
onMarkChannelRead,
onMarkChannelUnread,
onSelectChannel,
onToggleCollapsed,
selectedChannelId,
title,
unreadChannelIds,
sections,
assignments,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
}: {
browseAriaLabel: string;
browseTestId?: string;
createAriaLabel: string;
draggable?: boolean;
groupClassName?: string;
isCollapsed: boolean;
isActiveChannel: boolean;
items: Channel[];
listTestId: string;
onBrowse: () => void;
onCreateClick: () => void;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed: () => void;
selectedChannelId: string | null;
title: string;
unreadChannelIds: Set<string>;
hasUnread?: boolean;
onMarkAllRead?: () => void;
sections?: ChannelSection[];
assignments?: Record<string, string>;
onAssignChannel?: (channelId: string, sectionId: string) => void;
onUnassignChannel?: (channelId: string) => void;
onCreateSectionForChannel?: (channelId: string) => void;
}) {
const contentId = `sidebar-${listTestId}`;
const channelList =
items.length > 0 ? (
<SidebarMenu data-testid={listTestId}>
{items.map((channel) => (
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
{draggable ? (
<DraggableChannelRow channelId={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
onSelectChannel={onSelectChannel}
/>
</DraggableChannelRow>
) : (
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
onSelectChannel={onSelectChannel}
/>
)}
</SidebarMenuItem>
</ContextMenuTrigger>
<ContextMenuContent>
<ChannelContextMenuItems
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
sections={sections}
assignments={assignments}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onAssignChannel={onAssignChannel}
onUnassignChannel={onUnassignChannel}
onCreateSectionForChannel={onCreateSectionForChannel}
/>
</ContextMenuContent>
</ContextMenu>
))}
</SidebarMenu>
) : null;
const sectionContent = (
<SidebarGroup className={groupClassName}>
<div className="group/sidebar-section relative">
<SidebarGroupLabel asChild>
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
onClick={onToggleCollapsed}
type="button"
>
<span>{title}</span>
<ChevronDown
aria-hidden="true"
className={cn(
SECTION_LABEL_CHEVRON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
/>
</button>
</SidebarGroupLabel>
<SectionHeaderActions
browseAriaLabel={browseAriaLabel}
browseTestId={browseTestId}
className={SECTION_ACTION_VISIBILITY_CLASS}
createAriaLabel={createAriaLabel}
hasUnread={hasUnread}
onBrowse={onBrowse}
onCreateClick={onCreateClick}
onMarkAllRead={onMarkAllRead}
/>
</div>
{!isCollapsed ? (
<SidebarGroupContent id={contentId}>{channelList}</SidebarGroupContent>
) : null}
</SidebarGroup>
);
return draggable ? (
<DroppableUngroupedBody>{sectionContent}</DroppableUngroupedBody>
) : (
sectionContent
);
}
// ---------------------------------------------------------------------------
// CustomChannelSection — user-defined channel section with management actions
// ---------------------------------------------------------------------------
export function CustomChannelSection({
section,
channels,
hasUnread,
isCollapsed,
isActiveChannel,
selectedChannelId,
unreadChannelIds,
sections,
assignments,
isFirst,
isLast,
onToggleCollapsed,
onSelectChannel,
onMarkChannelRead,
onMarkChannelUnread,
onMarkSectionRead,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
onRenameSection,
onDeleteSection,
onMoveSectionUp,
onMoveSectionDown,
}: {
section: ChannelSection;
channels: Channel[];
hasUnread: boolean;
isCollapsed: boolean;
isActiveChannel: boolean;
selectedChannelId: string | null;
unreadChannelIds: Set<string>;
sections: ChannelSection[];
assignments: Record<string, string>;
isFirst: boolean;
isLast: boolean;
onToggleCollapsed: () => void;
onSelectChannel: (channelId: string) => void;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkSectionRead: () => void;
onAssignChannel: (channelId: string, sectionId: string) => void;
onUnassignChannel: (channelId: string) => void;
onCreateSectionForChannel: (channelId: string) => void;
onRenameSection: () => void;
onDeleteSection: () => void;
onMoveSectionUp: () => void;
onMoveSectionDown: () => void;
}) {
const contentId = `sidebar-section-${section.id}`;
return (
<SortableSectionShell sectionId={section.id}>
{({ dragHandleProps, isDragging }) => (
<DroppableSectionBody sectionId={section.id}>
<SidebarGroup className={cn(isDragging && "opacity-30")}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="group/sidebar-section relative"
{...dragHandleProps}
>
<SidebarGroupLabel asChild>
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
onClick={onToggleCollapsed}
type="button"
>
<GripVertical
className={cn(
"h-3 w-3 shrink-0 text-sidebar-foreground/30",
SECTION_ACTION_VISIBILITY_CLASS,
)}
aria-hidden="true"
/>
<span>{section.name}</span>
<ChevronDown
aria-hidden="true"
className={cn(
SECTION_LABEL_CHEVRON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
/>
</button>
</SidebarGroupLabel>
<div
className={cn(
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
SECTION_ACTION_VISIBILITY_CLASS,
)}
>
{hasUnread ? (
<button
aria-label="Mark all as read"
className={SECTION_ICON_BUTTON_CLASS}
onClick={(e) => {
e.stopPropagation();
onMarkSectionRead();
}}
title="Mark all as read"
type="button"
>
<CheckCheck className="h-3.5 w-3.5" />
</button>
) : null}
<button
aria-label="Rename section"
className={SECTION_ICON_BUTTON_CLASS}
onClick={(e) => {
e.stopPropagation();
onRenameSection();
}}
type="button"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
aria-label="Delete section"
className={SECTION_ICON_BUTTON_CLASS}
onClick={(e) => {
e.stopPropagation();
onDeleteSection();
}}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onRenameSection}>
<Pencil className="h-4 w-4" />
Rename section
</ContextMenuItem>
<ContextMenuItem disabled={isFirst} onClick={onMoveSectionUp}>
<ArrowUp className="h-4 w-4" />
Move up
</ContextMenuItem>
<ContextMenuItem disabled={isLast} onClick={onMoveSectionDown}>
<ArrowDown className="h-4 w-4" />
Move down
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={onDeleteSection}
>
<Trash2 className="h-4 w-4" />
Delete section
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{!isCollapsed ? (
<SidebarGroupContent id={contentId}>
{channels.length > 0 ? (
<SidebarMenu>
{channels.map((channel) => (
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
<DraggableChannelRow channelId={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel &&
selectedChannelId === channel.id
}
onSelectChannel={onSelectChannel}
/>
</DraggableChannelRow>
</SidebarMenuItem>
</ContextMenuTrigger>
<ContextMenuContent>
<ChannelContextMenuItems
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
sections={sections}
assignments={assignments}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onAssignChannel={onAssignChannel}
onUnassignChannel={onUnassignChannel}
onCreateSectionForChannel={
onCreateSectionForChannel
}
/>
</ContextMenuContent>
</ContextMenu>
))}
</SidebarMenu>
) : null}
</SidebarGroupContent>
) : null}
</SidebarGroup>
</DroppableSectionBody>
)}
</SortableSectionShell>
);
}
@@ -0,0 +1,299 @@
// biome-ignore format: keep compact to stay within file size limit
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Hash } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
// ---------------------------------------------------------------------------
// Data attribute shapes used on draggable / droppable items
// ---------------------------------------------------------------------------
export type DndChannelData = { type: "channel"; channelId: string };
export type DndSectionData = { type: "section"; sectionId: string };
export type DndSectionDropData = { type: "section-drop"; sectionId: string };
export type DndUngroupedData = { type: "ungrouped" };
// ---------------------------------------------------------------------------
// DraggableChannelRow — wraps a channel list item with useDraggable
// ---------------------------------------------------------------------------
export function DraggableChannelRow({
channelId,
children,
}: {
channelId: string;
children: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: channelId,
data: { type: "channel", channelId } satisfies DndChannelData,
});
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
className={cn("touch-none", isDragging && "opacity-30")}
>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// DroppableSectionBody — makes a section body accept channel drops
// ---------------------------------------------------------------------------
export function DroppableSectionBody({
sectionId,
children,
className,
}: {
sectionId: string;
children: React.ReactNode;
className?: string;
}) {
const droppableId = `section-drop:${sectionId}`;
const { setNodeRef, isOver } = useDroppable({
id: droppableId,
data: { type: "section-drop", sectionId } satisfies DndSectionDropData,
});
return (
<div
ref={setNodeRef}
className={cn(
"rounded-md transition-all",
isOver && "ring-2 ring-primary/30",
className,
)}
>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// DroppableUngroupedBody — makes the ungrouped Channels section a drop target
// ---------------------------------------------------------------------------
export function DroppableUngroupedBody({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const { setNodeRef, isOver } = useDroppable({
id: "ungrouped",
data: { type: "ungrouped" } satisfies DndUngroupedData,
});
return (
<div
ref={setNodeRef}
className={cn(
"rounded-md transition-all",
isOver && "ring-2 ring-primary/30",
className,
)}
>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// SortableSectionShell — wraps a CustomChannelSection header area
// ---------------------------------------------------------------------------
export function SortableSectionShell({
sectionId,
children,
}: {
sectionId: string;
children: (props: {
dragHandleProps: React.HTMLAttributes<HTMLElement>;
isDragging: boolean;
style: React.CSSProperties;
}) => React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sectionId,
data: { type: "section", sectionId } satisfies DndSectionData,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style}>
{children({
dragHandleProps: { ...attributes, ...listeners },
isDragging,
style,
})}
</div>
);
}
// ---------------------------------------------------------------------------
// DragOverlayChannel — ghost preview for a channel being dragged
// ---------------------------------------------------------------------------
export function DragOverlayChannel({ name }: { name: string }) {
return (
<div className="flex cursor-grabbing items-center gap-2 rounded-md bg-sidebar px-2 py-1.5 text-sm text-sidebar-foreground opacity-90 shadow-lg ring-1 ring-sidebar-border">
<Hash className="h-4 w-4 shrink-0 text-sidebar-foreground/60" />
<span className="truncate">{name}</span>
</div>
);
}
// ---------------------------------------------------------------------------
// DragOverlaySection — ghost preview for a section header being dragged
// ---------------------------------------------------------------------------
export function DragOverlaySection({ name }: { name: string }) {
return (
<div className="flex cursor-grabbing items-center gap-1 rounded-md bg-sidebar px-2 py-1 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/60 opacity-90 shadow-lg ring-1 ring-sidebar-border">
<span>{name}</span>
</div>
);
}
// ---------------------------------------------------------------------------
// SidebarDndContext — DndContext + SortableContext + DragOverlay wrapper
// ---------------------------------------------------------------------------
type SidebarDragItem =
| { type: "channel"; channelId: string; channelName: string }
| { type: "section"; sectionId: string; sectionName: string };
export function SidebarDndContext({
sectionIds,
channels,
sections,
children,
onAssignChannel,
onUnassignChannel,
onReorderSections,
}: {
sectionIds: string[];
channels: { id: string; name: string }[];
sections: { id: string; name: string }[];
children: React.ReactNode;
onAssignChannel: (channelId: string, sectionId: string) => void;
onUnassignChannel: (channelId: string) => void;
onReorderSections: (orderedIds: string[]) => void;
}) {
const [activeDragItem, setActiveDragItem] =
React.useState<SidebarDragItem | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
const handleDragStart = React.useCallback(
(event: DragStartEvent) => {
const data = event.active.data.current;
if (!data) return;
if (data.type === "channel") {
const ch = channels.find((c) => c.id === data.channelId);
if (ch)
setActiveDragItem({
type: "channel",
channelId: ch.id,
channelName: ch.name,
});
} else if (data.type === "section") {
const sec = sections.find((s) => s.id === data.sectionId);
if (sec)
setActiveDragItem({
type: "section",
sectionId: sec.id,
sectionName: sec.name,
});
}
},
[channels, sections],
);
const handleDragEnd = React.useCallback(
(event: DragEndEvent) => {
setActiveDragItem(null);
const { active, over } = event;
if (!over) return;
const activeData = active.data.current;
const overData = over.data.current;
if (!activeData) return;
if (activeData.type === "channel") {
const channelId = activeData.channelId as string;
if (overData?.type === "section-drop") {
onAssignChannel(channelId, overData.sectionId as string);
} else if (overData?.type === "ungrouped") {
onUnassignChannel(channelId);
}
} else if (activeData.type === "section") {
const overSectionId =
(overData?.sectionId as string | undefined) ?? (over.id as string);
const oldIdx = sectionIds.indexOf(active.id as string);
const newIdx = sectionIds.indexOf(overSectionId);
if (oldIdx !== -1 && newIdx !== -1 && oldIdx !== newIdx) {
onReorderSections(arrayMove(sectionIds, oldIdx, newIdx));
}
}
},
[sectionIds, onAssignChannel, onUnassignChannel, onReorderSections],
);
return (
<DndContext
collisionDetection={pointerWithin}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext
items={sectionIds}
strategy={verticalListSortingStrategy}
>
{children}
</SortableContext>
<DragOverlay>
{activeDragItem?.type === "channel" ? (
<DragOverlayChannel name={activeDragItem.channelName} />
) : activeDragItem?.type === "section" ? (
<DragOverlaySection name={activeDragItem.sectionName} />
) : null}
</DragOverlay>
</DndContext>
);
}
+57 -2
View File
@@ -21,6 +21,15 @@ importers:
desktop:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.6)
'@emoji-mart/data':
specifier: ^1.2.1
version: 1.2.1
@@ -449,6 +458,28 @@ packages:
cpu: [x64]
os: [win32]
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -1359,7 +1390,6 @@ packages:
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
@@ -2778,7 +2808,7 @@ packages:
engines: {node: '>= 0.4'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -2945,6 +2975,31 @@ snapshots:
'@biomejs/cli-win32-x64@2.4.16':
optional: true
'@dnd-kit/accessibility@3.1.1(react@19.2.6)':
dependencies:
react: 19.2.6
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.6)
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
react: 19.2.6
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.6)':
dependencies:
react: 19.2.6
tslib: 2.8.1
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1