diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 2bcb90564..5cd9b4e77 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -98,6 +98,9 @@ export function AppShell() { const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); + const [managedChannelId, setManagedChannelId] = React.useState( + null, + ); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [topbarSearchHidden, setTopbarSearchHidden] = React.useState(false); const [topbarSearchLoading, setTopbarSearchLoading] = React.useState(false); @@ -232,6 +235,12 @@ export function AppShell() { : null, [channels, selectedChannelId], ); + const managedChannel = React.useMemo(() => { + const targetChannelId = managedChannelId ?? selectedChannelId; + return targetChannelId + ? (channels.find((channel) => channel.id === targetChannelId) ?? null) + : null; + }, [channels, managedChannelId, selectedChannelId]); const handleThreadReplyDesktopNotification = React.useEffectEvent( (channelId: string, event: RelayEvent) => { @@ -682,7 +691,10 @@ export function AppShell() { markChannelRead, markChannelUnread, openCreateChannel: handleOpenCreateChannel, - openChannelManagement: () => { + openChannelManagement: (channelId?: string) => { + setManagedChannelId( + typeof channelId === "string" ? channelId : null, + ); setIsChannelManagementOpen(true); }, getChannelReadAt, @@ -904,16 +916,22 @@ export function AppShell() { )} { + setIsChannelManagementOpen(open); + if (!open) { + setManagedChannelId(null); + } + }} onDeleteActiveChannel={() => { setIsChannelManagementOpen(false); + setManagedChannelId(null); void goHome({ replace: true }); }} onSelectChannel={(channelId) => { diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index bd27e3019..584931d5f 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -11,7 +11,7 @@ type AppShellContextValue = { ) => void; markChannelUnread: (channelId: string) => void; openCreateChannel: () => void; - openChannelManagement: () => void; + openChannelManagement: (channelId?: string) => void; // NIP-RS read marker for a channel as a unix-seconds timestamp, or null // when unknown. Backed by the single AppShell-mounted ReadStateManager so // every surface (sidebar, home, badges) projects from the same source. diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index d3b49e85e..19fff59a4 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -79,12 +79,6 @@ function HomeRouteComponent() { { - void goChannel(channelId); - }} - onOpenContext={(channelId, messageId) => { - void goChannel(channelId, { messageId }); - }} /> ); } diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index 314d31ee1..d26d207cf 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -53,7 +53,7 @@ export function ChannelCanvas({ } if (canvasQuery.isLoading) { - return

Loading canvas…

; + return

Loading canvas...

; } if (canvasQuery.error instanceof Error) { @@ -75,7 +75,7 @@ export function ChannelCanvas({ data-testid="channel-canvas-editor" disabled={setCanvasMutation.isPending} onChange={(event) => setDraft(event.target.value)} - placeholder="Write your canvas content in Markdown…" + placeholder="Write your canvas content in Markdown..." value={draft} />
@@ -91,7 +91,7 @@ export function ChannelCanvas({ type="button" > - {setCanvasMutation.isPending ? "Saving…" : "Save canvas"} + {setCanvasMutation.isPending ? "Saving..." : "Save canvas"} + ) : ( + + )} + {isOwner ? ( + + + + + + + Delete channel? + + Delete {resolvedChannelName} from the workspace list. This + action cannot be undone. + + + {deleteChannelMutation.error instanceof Error ? ( +

+ {deleteChannelMutation.error.message} +

+ ) : null} + + + + + + + + +
+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index e708dfb9b..7c4652072 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -1,21 +1,29 @@ import { Archive, - ArchiveRestore, + BookOpenText, + ChevronLeft, Copy, DoorClosed, DoorOpen, FileText, - Hash, + Fingerprint, + Eye, Lock, MessageSquare, + Pencil, + Radio, + Type, Users, + X, Zap, } from "lucide-react"; import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; import { toast } from "sonner"; import { useArchiveChannelMutation, + useCanvasQuery, useChannelDetailsQuery, useChannelMembersQuery, useDeleteChannelMutation, @@ -31,38 +39,49 @@ import { formatTtlDuration, parseTtlDuration, } from "@/features/channels/lib/ephemeralChannel"; -import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useTheme } from "@/shared/theme/ThemeProvider"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; import { - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/shared/ui/sheet"; -import { Switch } from "@/shared/ui/switch"; + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; +import { + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelTitle, + auxiliaryPanelContentPaddingClass, +} from "@/shared/layout/AuxiliaryPanelHeader"; +import { + OverlayPanelBackdrop, + PANEL_BASE_CLASS, + PANEL_OVERLAY_CLASS, +} from "@/shared/ui/OverlayPanelBackdrop"; import { ChannelCanvas } from "./ChannelCanvas"; +import { + ChannelHero, + ChannelQuickAction, + CopyFieldRow, + FieldGroup, + getMarkdownPreviewText, + InfoFieldRow, + IngressRow, + NarrativeField, + NarrativeGroup, + ToggleRow, +} from "./ChannelManagementSheetRows"; +import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions"; type ChannelManagementSheetProps = { channel: Channel | null; currentPubkey?: string; + layout?: "overlay" | "split"; onDeleted?: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -70,63 +89,21 @@ type ChannelManagementSheetProps = { const DEFAULT_EPHEMERAL_TTL_SECONDS = 24 * 60 * 60; -function MetadataPill({ - icon: Icon, - label, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; -}) { - return ( -
- - {label} -
- ); -} - -function ChannelIdRow({ channelId }: { channelId: string }) { - async function handleCopyChannelId() { - await navigator.clipboard.writeText(channelId); - toast.success("Copied channel ID to clipboard"); - } - - return ( - - ); -} - export function ChannelManagementSheet({ channel, currentPubkey, + layout = "overlay", onDeleted, onOpenChange, open, }: ChannelManagementSheetProps) { const { isDark } = useTheme(); + const isSplitLayout = layout === "split"; const channelId = channel?.id ?? null; const detailsQuery = useChannelDetailsQuery(channelId, open); const membersQuery = useChannelMembersQuery(channelId, open); + const canvasQuery = useCanvasQuery(channelId, channelId !== null && open); const updateChannelDetailsMutation = useUpdateChannelMutation(channelId); - const updateChannelLifecycleMutation = useUpdateChannelMutation(channelId); const setTopicMutation = useSetChannelTopicMutation(channelId); const setPurposeMutation = useSetChannelPurposeMutation(channelId); const archiveChannelMutation = useArchiveChannelMutation(channelId); @@ -148,7 +125,8 @@ export function ChannelManagementSheet({ const isOwner = selfMember?.role === "owner"; const canManageChannel = selfMember?.role === "owner" || selfMember?.role === "admin"; - const canEditNarrative = selfMember !== null && detail?.channelType !== "dm"; + const canEditNarrative = + canManageChannel && selfMember !== null && detail?.channelType !== "dm"; const isArchived = detail?.archivedAt !== null && detail?.archivedAt !== undefined; const canJoin = @@ -173,9 +151,12 @@ export function ChannelManagementSheet({ const [isEphemeralDraft, setIsEphemeralDraft] = React.useState(false); const [ttlDraft, setTtlDraft] = React.useState(""); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); - const [isCreateWorkflowOpen, setIsCreateWorkflowOpen] = React.useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false); + const [activeView, setActiveView] = React.useState<"summary" | "canvas">( + "summary", + ); - // Sync drafts from server only when the sheet opens or the channel changes — + // Sync drafts from server only when the sheet opens or the channel changes - // not on every background refetch, which would clobber in-flight edits. const syncedForRef = React.useRef(null); React.useEffect(() => { @@ -183,7 +164,8 @@ export function ChannelManagementSheet({ // Reset on close so the next open re-syncs from server. syncedForRef.current = null; setIsDeleteDialogOpen(false); - setIsCreateWorkflowOpen(false); + setIsEditDialogOpen(false); + setActiveView("summary"); return; } if (!detail) { @@ -205,6 +187,7 @@ export function ChannelManagementSheet({ setTtlDraft( detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "", ); + setActiveView("summary"); }, [detail, open]); if (!channel) { @@ -227,7 +210,7 @@ export function ChannelManagementSheet({ } } - function handleSheetOpenChange(next: boolean) { + function handlePanelOpenChange(next: boolean) { if (!next) { handleDeleteDialogOpenChange(false); } @@ -253,498 +236,729 @@ export function ChannelManagementSheet({ nextVisibility !== currentVisibility || nextTtlSeconds !== currentTtlSeconds; - function handleSaveLifecycle() { - void updateChannelLifecycleMutation.mutateAsync({ - visibility: - nextVisibility !== currentVisibility ? nextVisibility : undefined, - ttlSeconds: - nextTtlSeconds !== currentTtlSeconds ? nextTtlSeconds : undefined, - }); - } - const resolvedChannel = detail ?? channel; + const nameDirty = nameDraft.trim() !== resolvedChannel.name.trim(); + const descriptionDirty = + descriptionDraft.trim() !== resolvedChannel.description.trim(); + const topicDirty = topicDraft.trim() !== (resolvedChannel.topic ?? "").trim(); + const purposeDirty = + purposeDraft.trim() !== (resolvedChannel.purpose ?? "").trim(); + const isSavingChannelEdits = + updateChannelDetailsMutation.isPending || + setTopicMutation.isPending || + setPurposeMutation.isPending; + const hasChannelEditChanges = + nameDirty || + descriptionDirty || + lifecycleDirty || + topicDirty || + purposeDirty; + const canSaveChannelEdits = + nameDraft.trim().length > 0 && + !ttlInvalid && + hasChannelEditChanges && + !isSavingChannelEdits; + const canvasContent = canvasQuery.data?.content?.trim() ?? ""; + const hasCanvas = canvasContent.length > 0; + const canvasPreview = hasCanvas + ? getMarkdownPreviewText(canvasContent) + : undefined; + const canOpenCanvas = hasCanvas || canEditNarrative; + + async function handleSaveChannelEdits() { + try { + if (nameDirty || descriptionDirty || lifecycleDirty) { + await updateChannelDetailsMutation.mutateAsync({ + description: descriptionDirty ? descriptionDraft.trim() : undefined, + name: nameDirty ? nameDraft.trim() : undefined, + ttlSeconds: + nextTtlSeconds !== currentTtlSeconds ? nextTtlSeconds : undefined, + visibility: + lifecycleDirty && nextVisibility !== currentVisibility + ? nextVisibility + : undefined, + }); + } + + if (topicDirty) { + await setTopicMutation.mutateAsync({ topic: topicDraft.trim() }); + } + + if (purposeDirty) { + await setPurposeMutation.mutateAsync({ purpose: purposeDraft.trim() }); + } + + setIsEditDialogOpen(false); + } catch { + // React Query stores mutation errors; keep the dialog open and render them. + } + } return ( - - - + {!isSplitLayout ? ( + + + handlePanelOpenChange(false)} + /> + + + ) : null} + {isSplitLayout ? ( + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} > - {channel.name} - - Channel settings - -
- - - - {isArchived ? ( - - ) : null} -
-
- -
- - {detailsQuery.error instanceof Error ? ( -

- {detailsQuery.error.message} -

- ) : null} - - {membersQuery.error instanceof Error ? ( -

- {membersQuery.error.message} -

- ) : null} - - {canJoin ? ( -
- - {joinChannelMutation.error instanceof Error ? ( -

- {joinChannelMutation.error.message} -

- ) : null} -
- ) : null} - -
- + + ) : ( + + + -
+ + + )} + + {canManageChannel ? ( + + +
+ + Edit channel + + Update settings for{" "} + {resolvedChannel.name}. + + + +
+
+
+ + setNameDraft(event.target.value)} + value={nameDraft} + /> +
+
+ +