mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
desktop: combine channel create and browse
This commit is contained in:
@@ -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={() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
389
desktop/src/features/channels/ui/ChannelCreateForm.tsx
Normal file
389
desktop/src/features/channels/ui/ChannelCreateForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user