desktop: combine channel create and browse

This commit is contained in:
Thomas Petersen
2026-06-19 13:26:13 -04:00
parent bee4159715
commit cdf27b3bc1
8 changed files with 790 additions and 714 deletions

View File

@@ -106,7 +106,6 @@ export function AppShell() {
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false);
const mainInsetRef = React.useRef<HTMLElement>(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={() => {

View File

@@ -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<void>;
onCreateChannel: (input: ChannelCreateInput) => Promise<void>;
onCreateForum: (input: ChannelCreateInput) => Promise<void>;
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({
<React.Suspense fallback={null}>
<ChannelBrowserDialog
channels={channels}
channelTypeFilter={browseDialogType}
channelTypeFilter={browseDialogType.channelType}
initialTab={browseDialogType.initialTab}
isCreating={
browseDialogType.channelType === "forum"
? isCreatingForum
: isCreatingChannel
}
onCreate={
browseDialogType.channelType === "forum"
? onCreateForum
: onCreateChannel
}
onJoinChannel={onBrowseChannelJoin}
onOpenChange={onBrowseDialogOpenChange}
onSelectChannel={onSelectChannel}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Compass, Search, X, type LucideIcon } from "lucide-react";
import * as React from "react";
import type { Channel } from "@/shared/api/types";
import {
@@ -16,8 +16,13 @@ import {
MODAL_SEARCH_SHELL_CLASS,
} from "@/shared/ui/modalSearchStyles";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import {
ChannelCreateForm,
type ChannelCreateInput,
} from "@/features/channels/ui/ChannelCreateForm";
const BROWSE_CHANNELS_SHORTCUT_HINT = "\u21E7\u2318O";
export type ChannelBrowserInitialTab = "browse" | "create";
type BrowserTab = "all" | "joined" | "archived";
function BrowseState({
@@ -45,7 +50,10 @@ function BrowseState({
type ChannelBrowserDialogProps = {
channels: Channel[];
channelTypeFilter?: "stream" | "forum";
initialTab?: ChannelBrowserInitialTab;
isCreating: boolean;
open: boolean;
onCreate: (input: ChannelCreateInput) => Promise<void>;
onOpenChange: (open: boolean) => void;
onJoinChannel: (channelId: string) => Promise<void>;
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<ChannelBrowserInitialTab>(initialTab);
const [query, setQuery] = React.useState("");
const [activeTab, setActiveTab] = React.useState<BrowserTab>("all");
const [browseTab, setBrowseTab] = React.useState<BrowserTab>("all");
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const [joiningChannelId, setJoiningChannelId] = React.useState<string | null>(
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({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="gap-0 overflow-hidden border-0 px-6 pb-0 pt-6"
className="max-w-2xl gap-0 overflow-hidden border-0 px-6 pb-0 pt-6"
data-testid={
isForumMode ? "forum-browser-dialog" : "channel-browser-dialog"
}
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus({ preventScroll: true });
}}
showCloseButton={false}
>
<DialogHeader className="space-y-0 pb-5">
<div className="flex items-center justify-between gap-4">
<DialogTitle>{browseTitle}</DialogTitle>
<div className="min-w-0">
<DialogTitle>{dialogTitle}</DialogTitle>
<p className="mt-1 text-sm text-muted-foreground">
{activeDialogTab === "browse"
? `Find existing ${entityLabel}s or join open conversations.`
: createDescription}
</p>
</div>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<label
className={MODAL_SEARCH_SHELL_CLASS}
htmlFor="channel-browser-search"
<Tabs
className="pt-4"
onValueChange={(value) => {
setActiveDialogTab(value as ChannelBrowserInitialTab);
setSelectedIndex(null);
}}
value={activeDialogTab}
>
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-hover/search:text-muted-foreground group-focus-within/search:text-foreground" />
<input
autoCapitalize="none"
autoCorrect="off"
className={MODAL_SEARCH_INPUT_CLASS}
data-testid="channel-browser-search"
id="channel-browser-search"
onChange={(event) => {
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}
/>
<span
className={`hidden shrink-0 text-xs text-muted-foreground/50 transition-opacity duration-150 ease-out group-focus-within/search:opacity-0 sm:block ${
query.length > 0 ? "opacity-0" : "opacity-100"
}`}
<TabsList className="h-auto w-full justify-start gap-2 rounded-xl bg-muted/45 p-1">
<TabsTrigger
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground/65 shadow-none transition-colors hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:ring-1 data-[state=active]:ring-border/70"
data-testid="channel-dialog-create-tab"
value="create"
>
Create
</TabsTrigger>
<TabsTrigger
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground/65 shadow-none transition-colors hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:ring-1 data-[state=active]:ring-border/70"
data-testid="channel-dialog-browse-tab"
value="browse"
>
Browse
</TabsTrigger>
</TabsList>
</Tabs>
{activeDialogTab === "browse" ? (
<label
className={MODAL_SEARCH_SHELL_CLASS}
htmlFor="channel-browser-search"
>
{BROWSE_CHANNELS_SHORTCUT_HINT}
</span>
</label>
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-hover/search:text-muted-foreground group-focus-within/search:text-foreground" />
<input
autoCapitalize="none"
autoCorrect="off"
className={MODAL_SEARCH_INPUT_CLASS}
data-testid="channel-browser-search"
id="channel-browser-search"
onChange={(event) => {
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}
/>
<span
className={`hidden shrink-0 text-xs text-muted-foreground/50 transition-opacity duration-150 ease-out group-focus-within/search:opacity-0 sm:block ${
query.length > 0 ? "opacity-0" : "opacity-100"
}`}
>
{BROWSE_CHANNELS_SHORTCUT_HINT}
</span>
</label>
) : null}
</DialogHeader>
<div className="h-[min(60vh,30rem)] overflow-hidden">
<div className="flex h-full flex-col">
<Tabs
className="shrink-0"
onValueChange={(value) => {
setActiveTab(value as BrowserTab);
setSelectedIndex(null);
}}
value={activeTab}
>
<TabsList
className="relative h-auto w-full justify-start gap-6 rounded-none border-b border-border/70 bg-transparent p-0 text-muted-foreground"
ref={tabListRef}
{activeDialogTab === "create" ? (
<div className="max-h-[min(68vh,34rem)] overflow-y-auto pb-6">
<ChannelCreateForm
channelKind={channelTypeFilter ?? "stream"}
isCreating={isCreating}
onCreate={onCreate}
onCreated={() => onOpenChange(false)}
/>
</div>
) : (
<div className="h-[min(60vh,30rem)] overflow-hidden">
<div className="flex h-full flex-col">
<Tabs
className="shrink-0"
onValueChange={(value) => {
setBrowseTab(value as BrowserTab);
setSelectedIndex(null);
}}
value={browseTab}
>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[-1px] left-0 h-0.5 w-px origin-left rounded-full bg-foreground opacity-0 transition-[transform,opacity] duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none data-[ready=true]:opacity-100"
data-ready={tabIndicator.width > 0}
data-testid="channel-browser-tab-indicator"
style={{
transform: `translate3d(${tabIndicator.left}px, 0, 0) scaleX(${tabIndicator.width})`,
}}
/>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.all = element;
}}
value="all"
<TabsList
className="relative h-auto w-full justify-start gap-6 rounded-none border-b border-border/70 bg-transparent p-0 text-muted-foreground"
ref={tabListRef}
>
{allTabLabel}
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.joined = element;
}}
value="joined"
>
Joined
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.archived = element;
}}
value="archived"
>
Archived
</TabsTrigger>
</TabsList>
</Tabs>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[-1px] left-0 h-0.5 w-px origin-left rounded-full bg-foreground opacity-0 transition-[transform,opacity] duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none data-[ready=true]:opacity-100"
data-ready={tabIndicator.width > 0}
data-testid="channel-browser-tab-indicator"
style={{
transform: `translate3d(${tabIndicator.left}px, 0, 0) scaleX(${tabIndicator.width})`,
}}
/>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.all = element;
}}
value="all"
>
{allTabLabel}
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.joined = element;
}}
value="joined"
>
Joined
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.archived = element;
}}
value="archived"
>
Archived
</TabsTrigger>
</TabsList>
</Tabs>
<div className="min-h-0 flex-1 overflow-y-auto pb-6 pt-4">
{orderedVisibleChannels.length === 0 ? (
<BrowseState
description={emptyDescription}
icon={deferredQuery.length > 0 ? Search : Compass}
title={emptyTitle}
/>
) : (
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55">
{orderedVisibleChannels.map((channel, index) => (
<ChannelCard
channel={channel}
isJoining={joiningChannelId === channel.id}
isSelected={index === selectedIndex}
key={channel.id}
onJoin={
!channel.isMember
? () => {
void handleJoin(channel.id);
}
: undefined
}
onSelect={() => handleSelect(channel)}
/>
))}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto pb-6 pt-4">
{orderedVisibleChannels.length === 0 ? (
<BrowseState
description={emptyDescription}
icon={deferredQuery.length > 0 ? Search : Compass}
title={emptyTitle}
/>
) : (
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55">
{orderedVisibleChannels.map((channel, index) => (
<ChannelCard
channel={channel}
isJoining={joiningChannelId === channel.id}
isSelected={index === selectedIndex}
key={channel.id}
onJoin={
!channel.isMember
? () => {
void handleJoin(channel.id);
}
: undefined
}
onSelect={() => handleSelect(channel)}
/>
))}
</div>
)}
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
);

View File

@@ -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<void>;
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<ChannelVisibility>("open");
const [ephemeral, setEphemeral] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
>(null);
const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement>(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<HTMLFormElement>) {
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 (
<form
className={cn("space-y-5", className)}
id="create-channel-form"
onSubmit={(event) => {
void handleSubmit(event);
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-name"
>
Name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-name"
disabled={isCreating}
id="create-channel-name"
onChange={(event) => {
setName(event.target.value);
setErrorMessage(null);
}}
placeholder={
channelKind === "forum" ? "design-discussions" : "release-notes"
}
ref={nameInputRef}
spellCheck={false}
value={name}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-description"
>
Description
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div className={CREATE_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-description"
disabled={isCreating}
id="create-channel-description"
onChange={(event) => {
setDescription(event.target.value);
setErrorMessage(null);
}}
placeholder={`What this ${kindLabel} is for`}
rows={2}
value={description}
/>
</div>
</div>
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl py-1",
isCreating && "opacity-50",
)}
data-testid="create-channel-visibility"
>
<label
className="min-w-0 cursor-pointer space-y-0.5"
htmlFor="create-channel-private"
>
<span className="block text-sm font-medium text-foreground">
Private
</span>
<span
className="block text-xs leading-4 text-muted-foreground/65"
id="create-channel-private-description"
>
Only members can invite people to this {kindLabel}.
</span>
</label>
<Switch
aria-describedby="create-channel-private-description"
checked={visibility === "private"}
className="shrink-0 shadow-none [&>span]:shadow-none"
data-testid="create-channel-private-toggle"
disabled={isCreating}
id="create-channel-private"
onCheckedChange={(checked) =>
setVisibility(checked ? "private" : "open")
}
/>
</div>
{templates.length > 0 ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-template"
>
Template
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<select
className="flex min-h-11 w-full rounded-xl border border-input bg-muted/40 px-3 py-2 text-sm text-muted-foreground/55 shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus:border-muted-foreground/50 focus:text-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
data-testid="create-channel-template"
disabled={isCreating}
id="create-channel-template"
onChange={(event) => handleTemplateChange(event.target.value)}
value={selectedTemplateId ?? ""}
>
<option value="">No template</option>
{templates.map((template: ChannelTemplate) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
<div className="flex w-full items-center justify-between gap-3 pt-1">
<Popover onOpenChange={setTypePopoverOpen} open={typePopoverOpen}>
<PopoverTrigger asChild>
<Button
aria-label={`Channel duration: ${durationLabel}`}
className="-ml-2.5 h-9 px-2.5 text-sm font-medium text-foreground hover:bg-muted/50"
disabled={isCreating}
type="button"
variant="ghost"
>
<DurationIcon className="h-4 w-4" />
{durationLabel}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1">
<div className="px-3 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70">
Channel type
</div>
<fieldset className="space-y-1">
<legend className="sr-only">Channel type</legend>
<ChannelDurationOption
ariaLabel="Ongoing channel"
checked={!ephemeral}
description="For projects, teams, and recurring conversations."
icon={Hash}
label="Ongoing"
onSelect={() => {
setEphemeral(false);
setTypePopoverOpen(false);
}}
/>
<ChannelDurationOption
ariaLabel="Ephemeral - auto-archives after 1 day of inactivity"
checked={ephemeral}
description="For quick discussions that archive automatically when inactive."
icon={ClockFading}
label="Temporary"
onSelect={() => {
setEphemeral(true);
setTypePopoverOpen(false);
}}
/>
</fieldset>
</PopoverContent>
</Popover>
<Button
data-testid="create-channel-submit"
disabled={isCreating || name.trim().length === 0}
type="submit"
>
{isCreating ? "Creating..." : `Create ${kindLabel}`}
</Button>
</div>
</form>
);
}
function ChannelDurationOption({
ariaLabel,
checked,
description,
icon: Icon,
label,
onSelect,
}: {
ariaLabel: string;
checked: boolean;
description: string;
icon: LucideIcon;
label: string;
onSelect: () => void;
}) {
return (
<label
className={cn(
"relative flex min-h-16 cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 text-left text-muted-foreground/75 transition-colors duration-150 ease-out hover:bg-muted/50 hover:text-foreground has-[:focus-visible]:outline-hidden has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
checked && "text-foreground",
)}
>
<input
aria-label={ariaLabel}
checked={checked}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
name="create-channel-duration"
onChange={onSelect}
type="radio"
/>
<span
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-muted-foreground/40",
checked && "border-foreground",
)}
aria-hidden="true"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-foreground transition-opacity duration-150",
checked ? "opacity-100" : "opacity-0",
)}
/>
</span>
<span className="grid min-w-0 flex-1 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 gap-y-1">
<Icon className="h-4 w-4 shrink-0 text-current" />
<span className="block text-sm font-medium leading-4 text-current">
{label}
</span>
<span
className={cn(
"col-span-2 block text-xs leading-4 text-muted-foreground/70",
checked && "text-muted-foreground/65",
)}
>
{description}
</span>
</span>
</label>
);
}

View File

@@ -33,7 +33,6 @@ import {
ChannelGroupSection,
CustomChannelSection,
} from "@/features/sidebar/ui/CustomChannelSection";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
@@ -51,7 +50,6 @@ import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider";
import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility";
import type {
Channel,
ChannelVisibility,
PresenceStatus,
Profile,
UserStatus,
@@ -88,8 +86,6 @@ type AppSidebarProps = {
homeBadgeCount: number;
isAddWorkspaceOpen?: boolean;
isLoading: boolean;
isCreatingChannel: boolean;
isCreatingForum: boolean;
isOpeningDm: boolean;
profile?: Profile;
selfPresenceStatus: PresenceStatus;
@@ -107,20 +103,7 @@ type AppSidebarProps = {
workspaces: Workspace[];
onAddWorkspace: (workspace: Workspace) => void;
onAddWorkspaceOpenChange?: (open: boolean) => void;
onCreateChannel: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onCreateForum: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onOpenChannelCreate: (channelKind: CreateChannelKind) => void;
onOpenAddWorkspace: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (channelId: string) => void;
@@ -150,8 +133,6 @@ type AppSidebarProps = {
isPresencePending?: boolean;
isNewDmOpen?: boolean;
onNewDmOpenChange?: (open: boolean) => void;
isCreateChannelOpen?: boolean;
onCreateChannelOpenChange?: (open: boolean) => void;
mutedChannelIds?: ReadonlySet<string>;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
@@ -172,8 +153,6 @@ export function AppSidebar({
homeBadgeCount,
isAddWorkspaceOpen,
isLoading,
isCreatingChannel,
isCreatingForum,
isOpeningDm,
profile,
selfPresenceStatus,
@@ -185,8 +164,7 @@ export function AppSidebar({
workspaces,
onAddWorkspace,
onAddWorkspaceOpenChange,
onCreateChannel,
onCreateForum,
onOpenChannelCreate,
onOpenAddWorkspace,
onHideDm,
onMarkChannelUnread,
@@ -210,8 +188,6 @@ export function AppSidebar({
isPresencePending,
isNewDmOpen: isNewDmOpenProp,
onNewDmOpenChange,
isCreateChannelOpen: isCreateChannelOpenProp,
onCreateChannelOpenChange,
mutedChannelIds,
onMuteChannel,
onUnmuteChannel,
@@ -273,25 +249,11 @@ export function AppSidebar({
};
}, []);
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null);
React.useEffect(() => {
if (!canShowSidebarUpdateCard) {
setIsSidebarUpdateCardDismissed(false);
}
}, [canShowSidebarUpdateCard]);
// Allow the create-channel dialog to be opened from outside (e.g. the
// ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift.
// When the external flag flips on, open the "stream" create dialog; the
// close direction is reported back via `onCreateChannelOpenChange` in the
// dialog's `onOpenChange` below.
React.useEffect(() => {
if (isCreateChannelOpenProp) {
setCreateDialogKind("stream");
}
}, [isCreateChannelOpenProp]);
const [collapsedGroups, setCollapsedGroups] = React.useState<
Record<CollapsibleSidebarGroup, boolean>
>({
@@ -443,30 +405,6 @@ export function AppSidebar({
unreadBelowCount,
} = useUnreadOverflow({ scrollRef, unreadChannelIds });
const isCreatingAny =
createDialogKind === "stream"
? isCreatingChannel
: createDialogKind === "forum"
? isCreatingForum
: false;
const handleCreateFromDialog = React.useCallback(
async (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => {
if (createDialogKind === "stream") {
await onCreateChannel(input);
} else if (createDialogKind === "forum") {
await onCreateForum(input);
}
},
[createDialogKind, onCreateChannel, onCreateForum],
);
return (
<Sidebar
className="!border-r-0"
@@ -672,7 +610,7 @@ export function AppSidebar({
isActiveChannel={selectedView === "channel"}
items={sectionBuckets.unassigned}
listTestId="stream-list"
onCreateClick={() => setCreateDialogKind("stream")}
onCreateClick={() => onOpenChannelCreate("stream")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
@@ -703,7 +641,7 @@ export function AppSidebar({
isActiveChannel={selectedView === "channel"}
items={forumChannels}
listTestId="forum-list"
onCreateClick={() => setCreateDialogKind("forum")}
onCreateClick={() => onOpenChannelCreate("forum")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
@@ -829,22 +767,6 @@ export function AppSidebar({
</div>
</div>
<CreateChannelDialog
channelKind={createDialogKind}
isCreating={isCreatingAny}
onOpenChange={(open) => {
if (!open) {
// If a "stream" dialog driven by the external controller is
// closing, report it back so AppShell's open state resets.
if (createDialogKind === "stream") {
onCreateChannelOpenChange?.(false);
}
setCreateDialogKind(null);
}
}}
onCreate={handleCreateFromDialog}
/>
<NewDirectMessageDialog
currentPubkey={currentPubkey}
isPending={isOpeningDm}

View File

@@ -1,46 +1,19 @@
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 {
ChannelCreateForm,
type ChannelCreateInput,
type ChannelCreateKind,
} from "@/features/channels/ui/ChannelCreateForm";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
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";
type ChannelKind = "stream" | "forum";
type CreateChannelDialogProps = {
/** Which kind of channel to create, or null when closed. */
channelKind: ChannelKind | null;
channelKind: ChannelCreateKind | null;
isCreating: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onCreate: (input: ChannelCreateInput) => Promise<void>;
};
// ---------------------------------------------------------------------------
// CreateChannelDialog
// ---------------------------------------------------------------------------
export function CreateChannelDialog({
channelKind,
isCreating,
@@ -48,95 +21,7 @@ export function CreateChannelDialog({
onCreate,
}: CreateChannelDialogProps) {
const open = channelKind !== null;
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [visibility, setVisibility] = React.useState<ChannelVisibility>("open");
const [ephemeral, setEphemeral] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
>(null);
const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const templatesQuery = useChannelTemplatesQuery();
const templates = templatesQuery.data ?? [];
const kindLabel = channelKind === "forum" ? "forum" : "channel";
const durationLabel = ephemeral ? "Temporary" : "Ongoing";
const DurationIcon = ephemeral ? ClockFading : Hash;
// Reset form state when dialog opens/closes or kind changes
React.useEffect(() => {
if (!open) return;
setName("");
setDescription("");
setVisibility("open");
setEphemeral(false);
setErrorMessage(null);
setSelectedTemplateId(null);
setTypePopoverOpen(false);
// Small delay to let dialog animation start before focusing
const timerId = globalThis.setTimeout(() => {
nameInputRef.current?.focus();
}, 50);
return () => globalThis.clearTimeout(timerId);
}, [open]);
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);
// Pre-fill fields from template (always overwrite to avoid stale values)
setDescription(template.description ?? "");
setVisibility(template.visibility);
// If the template's channel type differs from current dialog kind,
// we still apply the visibility but don't change the kind
// (kind is determined by how the dialog was opened)
setErrorMessage(null);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
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,
});
onOpenChange(false);
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: `Failed to create ${kindLabel}.`,
);
}
}
return (
<Dialog
@@ -146,283 +31,28 @@ export function CreateChannelDialog({
onOpenChange(nextOpen);
}}
>
<ChooserDialogContent
className="max-w-lg"
contentClassName="pt-3"
data-testid="create-channel-dialog"
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={`Create a new ${kindLabel}`}
description={
channelKind === "forum"
? "Forums organize threaded discussions around a topic."
: "Channels are real-time streams for team conversation."
}
footer={
<div className="flex w-full items-center justify-between gap-3">
<Popover onOpenChange={setTypePopoverOpen} open={typePopoverOpen}>
<PopoverTrigger asChild>
<Button
aria-label={`Channel duration: ${durationLabel}`}
className="-ml-2.5 h-9 px-2.5 text-sm font-medium text-foreground hover:bg-muted/50"
disabled={isCreating}
type="button"
variant="ghost"
>
<DurationIcon className="h-4 w-4" />
{durationLabel}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1">
<div className="px-3 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70">
Channel type
</div>
<fieldset className="space-y-1">
<legend className="sr-only">Channel type</legend>
<ChannelDurationOption
ariaLabel="Ongoing channel"
checked={!ephemeral}
description="For projects, teams, and recurring conversations."
icon={Hash}
label="Ongoing"
onSelect={() => {
setEphemeral(false);
setTypePopoverOpen(false);
}}
/>
<ChannelDurationOption
ariaLabel="Ephemeral - auto-archives after 1 day of inactivity"
checked={ephemeral}
description="For quick discussions that archive automatically when inactive."
icon={ClockFading}
label="Temporary"
onSelect={() => {
setEphemeral(true);
setTypePopoverOpen(false);
}}
/>
</fieldset>
</PopoverContent>
</Popover>
<Button
data-testid="create-channel-submit"
disabled={isCreating || name.trim().length === 0}
form="create-channel-form"
type="submit"
>
{isCreating ? "Creating..." : `Create ${kindLabel}`}
</Button>
</div>
}
>
<form
className="space-y-5"
id="create-channel-form"
onSubmit={(event) => {
void handleSubmit(event);
}}
{channelKind ? (
<ChooserDialogContent
className="max-w-lg"
contentClassName="pt-3"
data-testid="create-channel-dialog"
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={`Create a new ${kindLabel}`}
description={
channelKind === "forum"
? "Forums organize threaded discussions around a topic."
: "Channels are real-time streams for team conversation."
}
>
{/* Name */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-name"
>
Name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-name"
disabled={isCreating}
id="create-channel-name"
onChange={(event) => {
setName(event.target.value);
setErrorMessage(null);
}}
placeholder={
channelKind === "forum"
? "design-discussions"
: "release-notes"
}
ref={nameInputRef}
spellCheck={false}
value={name}
/>
</div>
</div>
{/* Description */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-description"
>
Description
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div className={CREATE_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-description"
disabled={isCreating}
id="create-channel-description"
onChange={(event) => {
setDescription(event.target.value);
setErrorMessage(null);
}}
placeholder={`What this ${kindLabel} is for`}
rows={2}
value={description}
/>
</div>
</div>
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl py-1",
isCreating && "opacity-50",
)}
data-testid="create-channel-visibility"
>
<label
className="min-w-0 cursor-pointer space-y-0.5"
htmlFor="create-channel-private"
>
<span className="block text-sm font-medium text-foreground">
Private
</span>
<span
className="block text-xs leading-4 text-muted-foreground/65"
id="create-channel-private-description"
>
Only members can invite people to this {kindLabel}.
</span>
</label>
<Switch
aria-describedby="create-channel-private-description"
checked={visibility === "private"}
className="shrink-0 shadow-none [&>span]:shadow-none"
data-testid="create-channel-private-toggle"
disabled={isCreating}
id="create-channel-private"
onCheckedChange={(checked) =>
setVisibility(checked ? "private" : "open")
}
/>
</div>
{/* Template Selector */}
{templates.length > 0 ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-template"
>
Template
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<select
className="flex min-h-11 w-full rounded-xl border border-input bg-muted/40 px-3 py-2 text-sm text-muted-foreground/55 shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus:border-muted-foreground/50 focus:text-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
data-testid="create-channel-template"
disabled={isCreating}
id="create-channel-template"
onChange={(event) => handleTemplateChange(event.target.value)}
value={selectedTemplateId ?? ""}
>
<option value="">No template</option>
{templates.map((template: ChannelTemplate) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
) : null}
{/* Error */}
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
</form>
</ChooserDialogContent>
<ChannelCreateForm
channelKind={channelKind}
isCreating={isCreating}
onCreate={onCreate}
onCreated={() => onOpenChange(false)}
/>
</ChooserDialogContent>
) : null}
</Dialog>
);
}
function ChannelDurationOption({
ariaLabel,
checked,
description,
icon: Icon,
label,
onSelect,
}: {
ariaLabel: string;
checked: boolean;
description: string;
icon: LucideIcon;
label: string;
onSelect: () => void;
}) {
return (
<label
className={cn(
"relative flex min-h-16 cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 text-left text-muted-foreground/75 transition-colors duration-150 ease-out hover:bg-muted/50 hover:text-foreground has-[:focus-visible]:outline-hidden has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
checked && "text-foreground",
)}
>
<input
aria-label={ariaLabel}
checked={checked}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
name="create-channel-duration"
onChange={onSelect}
type="radio"
/>
<span
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-muted-foreground/40",
checked && "border-foreground",
)}
aria-hidden="true"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-foreground transition-opacity duration-150",
checked ? "opacity-100" : "opacity-0",
)}
/>
</span>
<span className="grid min-w-0 flex-1 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 gap-y-1">
<Icon className="h-4 w-4 shrink-0 text-current" />
<span className="block text-sm font-medium leading-4 text-current">
{label}
</span>
<span
className={cn(
"col-span-2 block text-xs leading-4 text-muted-foreground/70",
checked && "text-muted-foreground/65",
)}
>
{description}
</span>
</span>
</label>
);
}

View File

@@ -28,7 +28,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
{
id: "browse-channels",
label: "Browse channels",
description: "Open the channel browser",
description: "Open the channel dialog on Browse",
keys: "⇧⌘O",
keysWindows: "Shift+Ctrl+O",
category: "Navigation",
@@ -44,7 +44,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
{
id: "new-channel",
label: "New channel",
description: "Open the create channel dialog",
description: "Open the channel dialog on Create",
keys: "⇧⌘N",
keysWindows: "Shift+Ctrl+N",
category: "Navigation",

View File

@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, type Page, test } from "@playwright/test";
import { installMockBridge, openChannelBrowser } from "../helpers/bridge";
@@ -6,30 +6,71 @@ test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
async function pressPrimaryShiftShortcut(page: Page, key: string) {
const isMacBrowser = await page.evaluate(() =>
/mac|iphone|ipad|ipod/i.test(navigator.platform),
);
if (isMacBrowser) {
await page.evaluate(() => {
await page.evaluate(
({ isMac, shortcutKey }) => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "O",
metaKey: true,
ctrlKey: !isMac,
key: shortcutKey,
metaKey: isMac,
shiftKey: true,
}),
);
});
} else {
await page.keyboard.press("Control+Shift+O");
}
},
{ isMac: isMacBrowser, shortcutKey: key },
);
}
test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await pressPrimaryShiftShortcut(page, "O");
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId("channel-dialog-browse-tab")).toHaveAttribute(
"data-state",
"active",
);
await expect(page.getByTestId("channel-browser-search")).toBeVisible();
});
test("new channel shortcut opens the combined dialog on create", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await pressPrimaryShiftShortcut(page, "N");
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId("channel-dialog-create-tab")).toHaveAttribute(
"data-state",
"active",
);
await expect(page.getByTestId("create-channel-name")).toBeVisible();
});
test("sidebar create button opens the combined dialog on create", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId("channel-dialog-create-tab")).toHaveAttribute(
"data-state",
"active",
);
await expect(page.getByTestId("create-channel-name")).toBeVisible();
});
test("channel browser shows channels not yet joined", async ({ page }) => {