From cdf27b3bc126c6194c6e424fa0bcaf453947d862 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 19 Jun 2026 13:26:13 -0400 Subject: [PATCH] desktop: combine channel create and browse --- desktop/src/app/AppShell.tsx | 109 +++-- desktop/src/app/AppShellOverlays.tsx | 28 +- .../channels/ui/ChannelBrowserDialog.tsx | 395 +++++++++------- .../channels/ui/ChannelCreateForm.tsx | 389 ++++++++++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 86 +--- .../sidebar/ui/CreateChannelDialog.tsx | 426 ++---------------- desktop/src/shared/lib/keyboard-shortcuts.ts | 4 +- desktop/tests/e2e/channel-browser.spec.ts | 67 ++- 8 files changed, 790 insertions(+), 714 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelCreateForm.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6e8882c0f2..674ba837f0 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -106,7 +106,6 @@ export function AppShell() { const [browseDialogType, setBrowseDialogType] = React.useState(null); const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); - const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); const mainInsetRef = React.useRef(null); const location = useLocation(); @@ -404,7 +403,7 @@ export function AppShell() { const openDmMutation = useOpenDmMutation(); const hideDmMutation = useHideDmMutation(); const handleOpenBrowseChannels = React.useCallback(() => { - setBrowseDialogType("stream"); + setBrowseDialogType({ channelType: "stream", initialTab: "browse" }); void refetchChannels(); }, [refetchChannels]); const handleOpenSearch = React.useCallback(() => { @@ -586,8 +585,9 @@ export function AppShell() { }, []); const handleOpenCreateChannel = React.useCallback(() => { - setIsCreateChannelOpen(true); - }, []); + setBrowseDialogType({ channelType: "stream", initialTab: "create" }); + void refetchChannels(); + }, [refetchChannels]); React.useLayoutEffect(() => { if (settingsOpen) { @@ -792,12 +792,9 @@ export function AppShell() { fallbackDisplayName={identityQuery.data?.displayName} homeBadgeCount={homeBadgeCount} isAddWorkspaceOpen={isAddWorkspaceOpen} - isCreatingChannel={createChannelMutation.isPending} - isCreatingForum={createForumMutation.isPending} isLoading={channelsQuery.isLoading} isOpeningDm={openDmMutation.isPending} isNewDmOpen={isNewDmOpen} - isCreateChannelOpen={isCreateChannelOpen} isPresencePending={presenceSession.isPending} onAddWorkspace={(workspace) => { const id = workspacesHook.addWorkspace(workspace); @@ -805,61 +802,19 @@ export function AppShell() { }} onAddWorkspaceOpenChange={setIsAddWorkspaceOpen} onNewDmOpenChange={setIsNewDmOpen} - onCreateChannelOpenChange={setIsCreateChannelOpen} + onOpenChannelCreate={(channelType) => { + setBrowseDialogType({ + channelType, + initialTab: "create", + }); + void refetchChannels(); + }} onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)} onUpdateWorkspace={workspacesHook.updateWorkspace} onRemoveWorkspace={workspacesHook.removeWorkspace} onSwitchWorkspace={workspacesHook.switchWorkspace} selfPresenceStatus={presenceSession.currentStatus} workspaces={workspacesHook.workspaces} - onCreateChannel={async ({ - description, - name, - visibility, - ttlSeconds, - templateId, - }) => { - const createdChannel = - await createChannelMutation.mutateAsync({ - name, - description, - channelType: "stream", - visibility, - ttlSeconds, - }); - - await applyCanvas( - templateId, - createdChannel.id, - name, - ); - await goChannel(createdChannel.id); - void applyAgents(templateId, createdChannel.id); - }} - onCreateForum={async ({ - description, - name, - visibility, - ttlSeconds, - templateId, - }) => { - const createdForum = - await createForumMutation.mutateAsync({ - name, - description, - channelType: "forum", - visibility, - ttlSeconds, - }); - - await applyCanvas( - templateId, - createdForum.id, - name, - ); - await goChannel(createdForum.id); - void applyAgents(templateId, createdForum.id); - }} onHideDm={handleHideDm} onMarkAllChannelsRead={markAllChannelsRead} onMarkChannelRead={markChannelRead} @@ -933,7 +888,49 @@ export function AppShell() { channels={channels} currentPubkey={identityQuery.data?.pubkey} isChannelManagementOpen={isChannelManagementOpen} + isCreatingChannel={createChannelMutation.isPending} + isCreatingForum={createForumMutation.isPending} onBrowseChannelJoin={handleBrowseChannelJoin} + onCreateChannel={async ({ + description, + name, + visibility, + ttlSeconds, + templateId, + }) => { + const createdChannel = + await createChannelMutation.mutateAsync({ + name, + description, + channelType: "stream", + visibility, + ttlSeconds, + }); + + await applyCanvas(templateId, createdChannel.id, name); + await goChannel(createdChannel.id); + void applyAgents(templateId, createdChannel.id); + }} + onCreateForum={async ({ + description, + name, + visibility, + ttlSeconds, + templateId, + }) => { + const createdForum = + await createForumMutation.mutateAsync({ + name, + description, + channelType: "forum", + visibility, + ttlSeconds, + }); + + await applyCanvas(templateId, createdForum.id, name); + await goChannel(createdForum.id); + void applyAgents(templateId, createdForum.id); + }} onBrowseDialogOpenChange={handleBrowseDialogOpenChange} onChannelManagementOpenChange={setIsChannelManagementOpen} onDeleteActiveChannel={() => { diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 44b99a0417..a85059001b 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -1,5 +1,7 @@ import * as React from "react"; +import type { ChannelBrowserInitialTab } from "@/features/channels/ui/ChannelBrowserDialog"; +import type { ChannelCreateInput } from "@/features/channels/ui/ChannelCreateForm"; import type { Channel } from "@/shared/api/types"; const ChannelBrowserDialog = React.lazy(async () => { @@ -12,7 +14,10 @@ const ChannelManagementSheet = React.lazy(async () => { return { default: module.ChannelManagementSheet }; }); -export type BrowseDialogType = "stream" | "forum" | null; +export type BrowseDialogType = { + channelType: "stream" | "forum"; + initialTab: ChannelBrowserInitialTab; +} | null; type AppShellOverlaysProps = { activeChannel: Channel | null; @@ -20,7 +25,11 @@ type AppShellOverlaysProps = { channels: Channel[]; currentPubkey?: string; isChannelManagementOpen: boolean; + isCreatingChannel: boolean; + isCreatingForum: boolean; onBrowseChannelJoin: (channelId: string) => Promise; + onCreateChannel: (input: ChannelCreateInput) => Promise; + onCreateForum: (input: ChannelCreateInput) => Promise; onBrowseDialogOpenChange: (open: boolean) => void; onChannelManagementOpenChange: (open: boolean) => void; onDeleteActiveChannel: () => void; @@ -33,7 +42,11 @@ export function AppShellOverlays({ channels, currentPubkey, isChannelManagementOpen, + isCreatingChannel, + isCreatingForum, onBrowseChannelJoin, + onCreateChannel, + onCreateForum, onBrowseDialogOpenChange, onChannelManagementOpenChange, onDeleteActiveChannel, @@ -45,7 +58,18 @@ export function AppShellOverlays({ Promise; onOpenChange: (open: boolean) => void; onJoinChannel: (channelId: string) => Promise; onSelectChannel: (channelId: string) => void; @@ -54,13 +62,18 @@ type ChannelBrowserDialogProps = { export function ChannelBrowserDialog({ channels, channelTypeFilter, + initialTab = "browse", + isCreating, open, + onCreate, onOpenChange, onJoinChannel, onSelectChannel, }: ChannelBrowserDialogProps) { + const [activeDialogTab, setActiveDialogTab] = + React.useState(initialTab); const [query, setQuery] = React.useState(""); - const [activeTab, setActiveTab] = React.useState("all"); + const [browseTab, setBrowseTab] = React.useState("all"); const [selectedIndex, setSelectedIndex] = React.useState(null); const [joiningChannelId, setJoiningChannelId] = React.useState( null, @@ -81,11 +94,14 @@ export function ChannelBrowserDialog({ const deferredQuery = React.useDeferredValue(query.trim().toLowerCase()); const isForumMode = channelTypeFilter === "forum"; - const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels"; + const dialogTitle = isForumMode ? "Forums" : "Channels"; const searchPlaceholder = isForumMode ? "Search forums by name or description" : "Search channels by name or description"; const entityLabel = isForumMode ? "forum" : "channel"; + const createDescription = isForumMode + ? "Start a threaded discussion space around a topic." + : "Start a real-time stream for team conversation."; const matchingChannels = React.useMemo(() => { const filtered = channels.filter( @@ -124,9 +140,9 @@ export function ChannelBrowserDialog({ ); const visibleChannels = - activeTab === "archived" + browseTab === "archived" ? archivedChannels - : activeTab === "joined" + : browseTab === "joined" ? joinedChannels : matchingChannels; @@ -142,9 +158,9 @@ export function ChannelBrowserDialog({ const updateTabIndicator = React.useCallback(() => { const list = tabListRef.current; - const trigger = tabTriggerRefs.current[activeTab]; + const trigger = tabTriggerRefs.current[browseTab]; - if (!open || !list || !trigger) { + if (!open || activeDialogTab !== "browse" || !list || !trigger) { return; } @@ -159,7 +175,7 @@ export function ChannelBrowserDialog({ ? current : nextIndicator, ); - }, [activeTab, open]); + }, [activeDialogTab, browseTab, open]); React.useLayoutEffect(() => { updateTabIndicator(); @@ -200,12 +216,25 @@ export function ChannelBrowserDialog({ React.useEffect(() => { if (!open) { setQuery(""); - setActiveTab("all"); + setBrowseTab("all"); setSelectedIndex(null); setJoiningChannelId(null); return; } - }, [open]); + + setActiveDialogTab(initialTab); + }, [initialTab, open]); + + React.useEffect(() => { + if (!open || activeDialogTab !== "browse") { + return; + } + + const timerId = globalThis.setTimeout(() => { + inputRef.current?.focus({ preventScroll: true }); + }, 50); + return () => globalThis.clearTimeout(timerId); + }, [activeDialogTab, open]); React.useEffect(() => { setSelectedIndex((current) => { @@ -239,17 +268,17 @@ export function ChannelBrowserDialog({ const emptyTitle = deferredQuery.length > 0 ? `No ${entityLabel}s match your search` - : activeTab === "archived" + : browseTab === "archived" ? `No archived ${entityLabel}s` - : activeTab === "joined" + : browseTab === "joined" ? `No joined ${entityLabel}s` : `No ${entityLabel}s to browse`; const emptyDescription = deferredQuery.length > 0 ? "Try a different name or keyword." - : activeTab === "archived" + : browseTab === "archived" ? `Archived ${entityLabel}s you have joined will appear here.` - : activeTab === "joined" + : browseTab === "joined" ? `${entityLabel[0].toUpperCase()}${entityLabel.slice(1)}s you join will appear here.` : `All open ${entityLabel}s are available in the sidebar. Create a new ${entityLabel} to get started.`; @@ -257,177 +286,221 @@ export function ChannelBrowserDialog({ { event.preventDefault(); - inputRef.current?.focus({ preventScroll: true }); }} showCloseButton={false} >
- {browseTitle} +
+ {dialogTitle} +

+ {activeDialogTab === "browse" + ? `Find existing ${entityLabel}s or join open conversations.` + : createDescription} +

+
Close
- + + { + setQuery(event.target.value); + setSelectedIndex(null); + }} + onKeyDown={(event) => { + if ( + event.key === "ArrowDown" && + orderedVisibleChannels.length > 0 + ) { + event.preventDefault(); + setSelectedIndex((current) => + current === null + ? 0 + : Math.min( + current + 1, + orderedVisibleChannels.length - 1, + ), + ); + return; + } + + if ( + event.key === "ArrowUp" && + orderedVisibleChannels.length > 0 + ) { + event.preventDefault(); + setSelectedIndex((current) => + current === null + ? orderedVisibleChannels.length - 1 + : Math.max(current - 1, 0), + ); + return; + } + + if ( + event.key === "Enter" && + !event.nativeEvent.isComposing && + orderedVisibleChannels.length > 0 + ) { + event.preventDefault(); + handleSelect(selectedItem ?? orderedVisibleChannels[0]); + } + }} + placeholder={searchPlaceholder} + ref={inputRef} + spellCheck={false} + type="text" + value={query} + /> + 0 ? "opacity-0" : "opacity-100" + }`} + > + {BROWSE_CHANNELS_SHORTCUT_HINT} + + + ) : null}
-
-
- { - setActiveTab(value as BrowserTab); - setSelectedIndex(null); - }} - value={activeTab} - > - + onOpenChange(false)} + /> +
+ ) : ( +
+
+ { + setBrowseTab(value as BrowserTab); + setSelectedIndex(null); + }} + value={browseTab} > - +
-
+ )}
); diff --git a/desktop/src/features/channels/ui/ChannelCreateForm.tsx b/desktop/src/features/channels/ui/ChannelCreateForm.tsx new file mode 100644 index 0000000000..521002cbb1 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCreateForm.tsx @@ -0,0 +1,389 @@ +import { ChevronDown, ClockFading, Hash, type LucideIcon } from "lucide-react"; +import * as React from "react"; + +import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; +import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Switch } from "@/shared/ui/switch"; +import { Textarea } from "@/shared/ui/textarea"; + +/** Default TTL for ephemeral channels: 1 day of inactivity. */ +const EPHEMERAL_TTL_SECONDS = 86400; +const CREATE_FIELD_SHELL_CLASS = + "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; +const CREATE_FIELD_CONTROL_CLASS = + "border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0"; +const CREATE_LABEL_OPTIONAL_CLASS = + "ml-1 text-xs font-normal text-muted-foreground/50"; + +export type ChannelCreateKind = "stream" | "forum"; + +export type ChannelCreateInput = { + name: string; + description?: string; + visibility: ChannelVisibility; + ttlSeconds?: number; + templateId?: string; +}; + +type ChannelCreateFormProps = { + channelKind: ChannelCreateKind; + className?: string; + isCreating: boolean; + onCreate: (input: ChannelCreateInput) => Promise; + onCreated?: () => void; +}; + +export function ChannelCreateForm({ + channelKind, + className, + isCreating, + onCreate, + onCreated, +}: ChannelCreateFormProps) { + const [name, setName] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [visibility, setVisibility] = React.useState("open"); + const [ephemeral, setEphemeral] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + const [selectedTemplateId, setSelectedTemplateId] = React.useState< + string | null + >(null); + const [typePopoverOpen, setTypePopoverOpen] = React.useState(false); + const nameInputRef = React.useRef(null); + + const templatesQuery = useChannelTemplatesQuery(); + const templates = templatesQuery.data ?? []; + + const kindLabel = channelKind === "forum" ? "forum" : "channel"; + const durationLabel = ephemeral ? "Temporary" : "Ongoing"; + const DurationIcon = ephemeral ? ClockFading : Hash; + + React.useEffect(() => { + const shouldFocusName = channelKind === "forum" || channelKind === "stream"; + + setName(""); + setDescription(""); + setVisibility("open"); + setEphemeral(false); + setErrorMessage(null); + setSelectedTemplateId(null); + setTypePopoverOpen(false); + + // Small delay to let dialog animation/tab activation settle before focusing. + const timerId = globalThis.setTimeout(() => { + if (shouldFocusName) { + nameInputRef.current?.focus(); + } + }, 50); + return () => globalThis.clearTimeout(timerId); + }, [channelKind]); + + function handleTemplateChange(templateId: string) { + if (!templateId) { + setSelectedTemplateId(null); + setDescription(""); + setVisibility("open"); + setErrorMessage(null); + return; + } + + const template = templates.find( + (t: ChannelTemplate) => t.id === templateId, + ); + if (!template) return; + + setSelectedTemplateId(templateId); + setDescription(template.description ?? ""); + setVisibility(template.visibility); + setErrorMessage(null); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const trimmedName = name.trim(); + if (!trimmedName) return; + + setErrorMessage(null); + + try { + await onCreate({ + name: trimmedName, + description: description.trim() || undefined, + visibility, + ttlSeconds: ephemeral ? EPHEMERAL_TTL_SECONDS : undefined, + templateId: selectedTemplateId ?? undefined, + }); + + onCreated?.(); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : `Failed to create ${kindLabel}.`, + ); + } + } + + return ( +
{ + void handleSubmit(event); + }} + > +
+ +
+ { + setName(event.target.value); + setErrorMessage(null); + }} + placeholder={ + channelKind === "forum" ? "design-discussions" : "release-notes" + } + ref={nameInputRef} + spellCheck={false} + value={name} + /> +
+
+ +
+ +
+