diff --git a/desktop/package.json b/desktop/package.json index d943e86d8..af5684c1d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b52c3d636..fa26f0285 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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 diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 84bbd8fb1..304c1d1ad 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -101,6 +101,7 @@ export class ReadStateManager { } markContextRead(contextId: string, unixTimestamp: number): void { + this.forcedContexts.delete(contextId); this.advanceContext(contextId, unixTimestamp, { publishable: true }); } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index a2f150f01..bfdb4b351 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -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)) { diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts new file mode 100644 index 000000000..a5e22528c --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -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; +}; + +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; + const sections: ChannelSection[] = Array.isArray(obj.sections) + ? obj.sections.filter( + (entry: unknown): entry is ChannelSection => + typeof entry === "object" && + entry !== null && + typeof (entry as Record).id === "string" && + typeof (entry as Record).name === "string" && + typeof (entry as Record).order === "number", + ) + : []; + const assignments: Record = + typeof obj.assignments === "object" && + obj.assignments !== null && + !Array.isArray(obj.assignments) + ? Object.fromEntries( + Object.entries(obj.assignments as Record).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; + } +} diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts new file mode 100644 index 000000000..2052299b0 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -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; + 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(() => { + 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( + () => 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, + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 1e36ce978..afe707191 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -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 { + CreateSectionDialog, + DeleteSectionAlertDialog, + RenameSectionDialog, +} from "@/features/sidebar/ui/ChannelSectionDialogs"; import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton"; +import { SidebarSection } from "@/features/sidebar/ui/SidebarSection"; import { - ChannelMenuButton, - SidebarSection, -} from "@/features/sidebar/ui/SidebarSection"; + 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 ( -
- {hasUnread && onMarkAllRead ? ( - - ) : null} - - -
- ); -} - -// --------------------------------------------------------------------------- -// 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; - hasUnread?: boolean; - onMarkAllRead?: () => void; -}) { - const contentId = `sidebar-${listTestId}`; - - return ( - -
- - - - -
- {!isCollapsed ? ( - - {items.length > 0 ? ( - - {items.map((channel) => ( - - - - - - - - {unreadChannelIds.has(channel.id) ? ( - - onMarkChannelRead(channel.id, channel.lastMessageAt) - } - > - - Mark as read - - ) : ( - - onMarkChannelUnread(channel.id, channel.lastMessageAt) - } - > - - Mark unread - - )} - - - ))} - - ) : null} - - ) : null} -
- ); -} - // --------------------------------------------------------------------------- // AppSidebar // --------------------------------------------------------------------------- @@ -453,10 +244,88 @@ export function AppSidebar({ [], ); + const [collapsedSections, setCollapsedSections] = React.useState< + Record + >({}); + 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(null); + const [deleteSectionTarget, setDeleteSectionTarget] = + React.useState(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 = {}; + 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 ? ( <> - 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} - /> + + {channelSections.map((section, idx) => ( + + 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)} + /> + ))} + 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} + /> + + + { + if (!open) { + setCreateSectionState({ open: false, pendingChannelId: null }); + } + }} + onConfirm={handleCreateSectionConfirm} + /> + + { + if (!open) setRenameSectionTarget(null); + }} + sectionName={renameSectionTarget?.name ?? ""} + onConfirm={(newName) => { + if (renameSectionTarget) { + renameSection(renameSectionTarget.id, newName); + } + setRenameSectionTarget(null); + }} + /> + + { + 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); + }} + /> ); } diff --git a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx new file mode 100644 index 000000000..c25f14ddf --- /dev/null +++ b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx @@ -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(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) { + event.preventDefault(); + const trimmed = name.trim(); + if (isConfirmDisabled(trimmed)) return; + onConfirm(trimmed); + } + + return ( + + + + {title} + {description} + +
+ setName(event.target.value)} + placeholder="Section name" + ref={inputRef} + spellCheck={false} + value={name} + /> +
+ + + + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// CreateSectionDialog +// --------------------------------------------------------------------------- + +export type CreateSectionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (name: string) => void; +}; + +export function CreateSectionDialog({ + open, + onOpenChange, + onConfirm, +}: CreateSectionDialogProps) { + return ( + 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 ( + + 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 ( + + + + Delete section + {description} + + + Cancel + + Delete + + + + + ); +} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx new file mode 100644 index 000000000..3dab3da05 --- /dev/null +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -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; + onAssignChannel: (channelId: string, sectionId: string) => void; + onUnassignChannel: (channelId: string) => void; + onCreateSectionForChannel: (channelId: string) => void; +}) { + const currentSectionId = assignments[channelId]; + + return ( + + Move to section + + {sections.map((section) => ( + onAssignChannel(channelId, section.id)} + > + {currentSectionId === section.id ? ( + + ) : ( + + )} + {section.name} + + ))} + {sections.length > 0 ? : null} + onCreateSectionForChannel(channelId)}> + + New section... + + {currentSectionId ? ( + onUnassignChannel(channelId)}> + Remove from section + + ) : null} + + + ); +} + +// --------------------------------------------------------------------------- +// 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; + 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 ? ( + onMarkChannelRead(channel.id, channel.lastMessageAt)} + > + + Mark as read + + ) : ( + onMarkChannelUnread(channel.id, channel.lastMessageAt)} + > + + Mark unread + + )} + {sections && + assignments && + onAssignChannel && + onUnassignChannel && + 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 ( +
+ {hasUnread && onMarkAllRead ? ( + + ) : null} + + +
+ ); +} + +// --------------------------------------------------------------------------- +// 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; + hasUnread?: boolean; + onMarkAllRead?: () => void; + sections?: ChannelSection[]; + assignments?: Record; + onAssignChannel?: (channelId: string, sectionId: string) => void; + onUnassignChannel?: (channelId: string) => void; + onCreateSectionForChannel?: (channelId: string) => void; +}) { + const contentId = `sidebar-${listTestId}`; + + const channelList = + items.length > 0 ? ( + + {items.map((channel) => ( + + + + {draggable ? ( + + + + ) : ( + + )} + + + + + + + ))} + + ) : null; + + const sectionContent = ( + +
+ + + + +
+ {!isCollapsed ? ( + {channelList} + ) : null} +
+ ); + + return draggable ? ( + {sectionContent} + ) : ( + 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; + sections: ChannelSection[]; + assignments: Record; + 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 ( + + {({ dragHandleProps, isDragging }) => ( + + + + +
+ + + +
+ {hasUnread ? ( + + ) : null} + + +
+
+
+ + + + Rename section + + + + Move up + + + + Move down + + + + + Delete section + + +
+ {!isCollapsed ? ( + + {channels.length > 0 ? ( + + {channels.map((channel) => ( + + + + + + + + + + + + + ))} + + ) : null} + + ) : null} +
+
+ )} +
+ ); +} diff --git a/desktop/src/features/sidebar/ui/SidebarDnd.tsx b/desktop/src/features/sidebar/ui/SidebarDnd.tsx new file mode 100644 index 000000000..8a5a09caa --- /dev/null +++ b/desktop/src/features/sidebar/ui/SidebarDnd.tsx @@ -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 ( +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// SortableSectionShell — wraps a CustomChannelSection header area +// --------------------------------------------------------------------------- + +export function SortableSectionShell({ + sectionId, + children, +}: { + sectionId: string; + children: (props: { + dragHandleProps: React.HTMLAttributes; + 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 ( +
+ {children({ + dragHandleProps: { ...attributes, ...listeners }, + isDragging, + style, + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// DragOverlayChannel — ghost preview for a channel being dragged +// --------------------------------------------------------------------------- + +export function DragOverlayChannel({ name }: { name: string }) { + return ( +
+ + {name} +
+ ); +} + +// --------------------------------------------------------------------------- +// DragOverlaySection — ghost preview for a section header being dragged +// --------------------------------------------------------------------------- + +export function DragOverlaySection({ name }: { name: string }) { + return ( +
+ {name} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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(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 ( + + + {children} + + + {activeDragItem?.type === "channel" ? ( + + ) : activeDragItem?.type === "section" ? ( + + ) : null} + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2013cf79f..76df97392 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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