mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(sidebar): replace Browse Channels button with search icons (#118)
This commit is contained in:
@@ -67,7 +67,12 @@ export function AppShell() {
|
||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||
React.useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [isBrowseChannelsOpen, setIsBrowseChannelsOpen] = React.useState(false);
|
||||
const [browseDialogType, setBrowseDialogType] = React.useState<
|
||||
"stream" | "forum" | null
|
||||
>(null);
|
||||
const handleBrowseDialogOpenChange = React.useCallback((open: boolean) => {
|
||||
setBrowseDialogType(open ? "stream" : null);
|
||||
}, []);
|
||||
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
|
||||
null,
|
||||
);
|
||||
@@ -547,7 +552,11 @@ export function AppShell() {
|
||||
openChannelView(createdForum.id);
|
||||
}}
|
||||
onOpenBrowseChannels={() => {
|
||||
setIsBrowseChannelsOpen(true);
|
||||
setBrowseDialogType("stream");
|
||||
void refetchChannels();
|
||||
}}
|
||||
onOpenBrowseForums={() => {
|
||||
setBrowseDialogType("forum");
|
||||
void refetchChannels();
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
@@ -707,10 +716,11 @@ export function AppShell() {
|
||||
|
||||
<ChannelBrowserDialog
|
||||
channels={channels}
|
||||
channelTypeFilter={browseDialogType ?? "stream"}
|
||||
onJoinChannel={handleBrowseChannelJoin}
|
||||
onOpenChange={setIsBrowseChannelsOpen}
|
||||
onOpenChange={handleBrowseDialogOpenChange}
|
||||
onSelectChannel={handleOpenChannel}
|
||||
open={isBrowseChannelsOpen}
|
||||
open={browseDialogType !== null}
|
||||
/>
|
||||
|
||||
<SearchDialog
|
||||
|
||||
@@ -72,6 +72,7 @@ function BrowseState({
|
||||
|
||||
type ChannelBrowserDialogProps = {
|
||||
channels: Channel[];
|
||||
channelTypeFilter?: "stream" | "forum";
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onJoinChannel: (channelId: string) => Promise<void>;
|
||||
@@ -80,6 +81,7 @@ type ChannelBrowserDialogProps = {
|
||||
|
||||
export function ChannelBrowserDialog({
|
||||
channels,
|
||||
channelTypeFilter,
|
||||
open,
|
||||
onOpenChange,
|
||||
onJoinChannel,
|
||||
@@ -93,12 +95,23 @@ export function ChannelBrowserDialog({
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
|
||||
|
||||
const isForumMode = channelTypeFilter === "forum";
|
||||
const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels";
|
||||
const browseDescription = isForumMode
|
||||
? "Discover and join open forums."
|
||||
: "Discover and join open channels.";
|
||||
const searchPlaceholder = isForumMode
|
||||
? "Search forums by name or description"
|
||||
: "Search channels by name or description";
|
||||
const entityLabel = isForumMode ? "forum" : "channel";
|
||||
|
||||
const browsableChannels = React.useMemo(() => {
|
||||
const filtered = channels.filter(
|
||||
(channel) =>
|
||||
channel.channelType !== "dm" &&
|
||||
channel.visibility === "open" &&
|
||||
!channel.archivedAt,
|
||||
!channel.archivedAt &&
|
||||
(channelTypeFilter ? channel.channelType === channelTypeFilter : true),
|
||||
);
|
||||
|
||||
if (deferredQuery.length === 0) {
|
||||
@@ -110,7 +123,7 @@ export function ChannelBrowserDialog({
|
||||
channel.name.toLowerCase().includes(deferredQuery) ||
|
||||
channel.description.toLowerCase().includes(deferredQuery),
|
||||
);
|
||||
}, [channels, deferredQuery]);
|
||||
}, [channels, channelTypeFilter, deferredQuery]);
|
||||
|
||||
const notJoined = React.useMemo(
|
||||
() => browsableChannels.filter((channel) => !channel.isMember),
|
||||
@@ -129,6 +142,10 @@ export function ChannelBrowserDialog({
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isForumMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key.toLowerCase() !== BROWSE_CHANNELS_SHORTCUT_KEY ||
|
||||
@@ -147,7 +164,7 @@ export function ChannelBrowserDialog({
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onOpenChange]);
|
||||
}, [isForumMode, onOpenChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -204,18 +221,18 @@ export function ChannelBrowserDialog({
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="gap-0 overflow-hidden p-0"
|
||||
data-testid="channel-browser-dialog"
|
||||
data-testid={
|
||||
isForumMode ? "forum-browser-dialog" : "channel-browser-dialog"
|
||||
}
|
||||
>
|
||||
<DialogHeader className="border-b border-border/80 px-6 py-5">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
|
||||
<Compass className="h-4 w-4" />
|
||||
</span>
|
||||
Browse Channels
|
||||
{browseTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Discover and join open channels.
|
||||
</DialogDescription>
|
||||
<DialogDescription>{browseDescription}</DialogDescription>
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-sm">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -249,7 +266,7 @@ export function ChannelBrowserDialog({
|
||||
handleSelect(selectedItem);
|
||||
}
|
||||
}}
|
||||
placeholder="Search channels by name or description"
|
||||
placeholder={searchPlaceholder}
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
/>
|
||||
@@ -265,13 +282,13 @@ export function ChannelBrowserDialog({
|
||||
<BrowseState
|
||||
description="Try a different name or keyword."
|
||||
icon={Search}
|
||||
title="No channels match your search"
|
||||
title={`No ${entityLabel}s match your search`}
|
||||
/>
|
||||
) : (
|
||||
<BrowseState
|
||||
description="All open channels are available in the sidebar. Create a new channel to get started."
|
||||
description={`All open ${entityLabel}s are available in the sidebar. Create a new ${entityLabel} to get started.`}
|
||||
icon={Compass}
|
||||
title="No channels to browse"
|
||||
title={`No ${entityLabel}s to browse`}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -280,7 +297,7 @@ export function ChannelBrowserDialog({
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<span>
|
||||
{notJoined.length} channel
|
||||
{notJoined.length} {entityLabel}
|
||||
{notJoined.length !== 1 ? "s" : ""} to join
|
||||
</span>
|
||||
<span>Enter to join</span>
|
||||
@@ -333,7 +350,7 @@ export function ChannelBrowserDialog({
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
|
||||
Showing open channels. Private channels require an invite.
|
||||
Showing open {entityLabel}s. Private {entityLabel}s require an invite.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Bot, Compass, Home, PenSquare, Plus, Search } from "lucide-react";
|
||||
import { Bot, Home, PenSquare, Plus, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
@@ -32,6 +32,17 @@ import {
|
||||
SidebarSeparator,
|
||||
} from "@/shared/ui/sidebar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SECTION_ICON_BUTTON_CLASS =
|
||||
"flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AppSidebarProps = {
|
||||
channels: Channel[];
|
||||
currentPubkey?: string;
|
||||
@@ -56,6 +67,7 @@ type AppSidebarProps = {
|
||||
description?: string;
|
||||
}) => Promise<void>;
|
||||
onOpenBrowseChannels: () => void;
|
||||
onOpenBrowseForums: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
|
||||
onSelectAgents: () => void;
|
||||
@@ -64,99 +76,243 @@ type AppSidebarProps = {
|
||||
onSelectSettings: () => void;
|
||||
};
|
||||
|
||||
function StreamsSection({
|
||||
items,
|
||||
// ---------------------------------------------------------------------------
|
||||
// useCreateForm — shared state + handler for channel/forum creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useCreateForm(
|
||||
onCreate: (input: { name: string; description?: string }) => Promise<void>,
|
||||
entityLabel: string,
|
||||
) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [draftName, setDraftName] = React.useState("");
|
||||
const [draftDescription, setDraftDescription] = React.useState("");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
function toggle() {
|
||||
setErrorMessage(undefined);
|
||||
setIsOpen((current) => !current);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setErrorMessage(undefined);
|
||||
setDraftName("");
|
||||
setDraftDescription("");
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function changeName(value: string) {
|
||||
setErrorMessage(undefined);
|
||||
setDraftName(value);
|
||||
}
|
||||
|
||||
function changeDescription(value: string) {
|
||||
setErrorMessage(undefined);
|
||||
setDraftDescription(value);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = draftName.trim();
|
||||
const description = draftDescription.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(undefined);
|
||||
|
||||
try {
|
||||
await onCreate({
|
||||
name,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
setDraftName("");
|
||||
setDraftDescription("");
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to create ${entityLabel}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
draftName,
|
||||
draftDescription,
|
||||
errorMessage,
|
||||
inputRef,
|
||||
toggle,
|
||||
cancel,
|
||||
changeName,
|
||||
changeDescription,
|
||||
handleSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SectionHeaderActions — search + create icon buttons for section headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeaderActions({
|
||||
browseAriaLabel,
|
||||
browseTestId,
|
||||
createAriaLabel,
|
||||
closeAriaLabel,
|
||||
isCreateOpen,
|
||||
isCreatingChannel,
|
||||
draftName,
|
||||
draftDescription,
|
||||
createInputRef,
|
||||
createErrorMessage,
|
||||
onBrowse,
|
||||
onToggleCreate,
|
||||
onChangeName,
|
||||
onChangeDescription,
|
||||
onCreateChannel,
|
||||
onCancelCreate,
|
||||
onSelectChannel,
|
||||
isActiveChannel,
|
||||
selectedChannelId,
|
||||
unreadChannelIds,
|
||||
}: {
|
||||
items: Channel[];
|
||||
browseAriaLabel: string;
|
||||
browseTestId?: string;
|
||||
createAriaLabel: string;
|
||||
closeAriaLabel: string;
|
||||
isCreateOpen: boolean;
|
||||
isCreatingChannel: boolean;
|
||||
draftName: string;
|
||||
draftDescription: string;
|
||||
createInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
createErrorMessage?: string;
|
||||
onBrowse: () => void;
|
||||
onToggleCreate: () => void;
|
||||
onChangeName: (value: string) => void;
|
||||
onChangeDescription: (value: string) => void;
|
||||
onCreateChannel: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
onCancelCreate: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
isActiveChannel: boolean;
|
||||
selectedChannelId: string | null;
|
||||
unreadChannelIds: Set<string>;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup className="pt-1">
|
||||
<SidebarGroupLabel>Channels</SidebarGroupLabel>
|
||||
<SidebarGroupAction
|
||||
<div className="absolute right-1 top-3 flex items-center gap-0.5">
|
||||
<button
|
||||
aria-label={browseAriaLabel}
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
data-testid={browseTestId}
|
||||
onClick={onBrowse}
|
||||
type="button"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
aria-expanded={isCreateOpen}
|
||||
aria-label={isCreateOpen ? "Close new stream form" : "Create a stream"}
|
||||
className="top-3 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
|
||||
aria-label={isCreateOpen ? closeAriaLabel : createAriaLabel}
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={onToggleCreate}
|
||||
type="button"
|
||||
>
|
||||
<Plus
|
||||
className={
|
||||
isCreateOpen
|
||||
? "rotate-45 transition-transform"
|
||||
: "transition-transform"
|
||||
? "h-4 w-4 rotate-45 transition-transform"
|
||||
: "h-4 w-4 transition-transform"
|
||||
}
|
||||
/>
|
||||
</SidebarGroupAction>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChannelGroupSection — unified Channels / Forums section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChannelGroupSection({
|
||||
browseAriaLabel,
|
||||
browseTestId,
|
||||
closeAriaLabel,
|
||||
createAriaLabel,
|
||||
createFormTestId,
|
||||
createNameTestId,
|
||||
createDescriptionTestId,
|
||||
groupClassName,
|
||||
isActiveChannel,
|
||||
isCreating,
|
||||
items,
|
||||
listTestId,
|
||||
namePlaceholder,
|
||||
descriptionPlaceholder,
|
||||
onBrowse,
|
||||
onSelectChannel,
|
||||
selectedChannelId,
|
||||
title,
|
||||
unreadChannelIds,
|
||||
form,
|
||||
}: {
|
||||
browseAriaLabel: string;
|
||||
browseTestId?: string;
|
||||
closeAriaLabel: string;
|
||||
createAriaLabel: string;
|
||||
createFormTestId: string;
|
||||
createNameTestId: string;
|
||||
createDescriptionTestId: string;
|
||||
groupClassName?: string;
|
||||
isActiveChannel: boolean;
|
||||
isCreating: boolean;
|
||||
items: Channel[];
|
||||
listTestId: string;
|
||||
namePlaceholder: string;
|
||||
descriptionPlaceholder: string;
|
||||
onBrowse: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
selectedChannelId: string | null;
|
||||
title: string;
|
||||
unreadChannelIds: Set<string>;
|
||||
form: ReturnType<typeof useCreateForm>;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup className={groupClassName}>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SectionHeaderActions
|
||||
browseAriaLabel={browseAriaLabel}
|
||||
browseTestId={browseTestId}
|
||||
closeAriaLabel={closeAriaLabel}
|
||||
createAriaLabel={createAriaLabel}
|
||||
isCreateOpen={form.isOpen}
|
||||
onBrowse={onBrowse}
|
||||
onToggleCreate={form.toggle}
|
||||
/>
|
||||
<SidebarGroupContent>
|
||||
{isCreateOpen ? (
|
||||
{form.isOpen ? (
|
||||
<form
|
||||
className="mb-2 space-y-2 rounded-lg border border-sidebar-border/70 bg-sidebar-accent/60 p-2"
|
||||
data-testid="create-stream-form"
|
||||
onSubmit={onCreateChannel}
|
||||
data-testid={createFormTestId}
|
||||
onSubmit={(event) => {
|
||||
void form.handleSubmit(event);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-stream-name"
|
||||
disabled={isCreatingChannel}
|
||||
onChange={(event) => onChangeName(event.target.value)}
|
||||
placeholder="release-notes"
|
||||
ref={createInputRef}
|
||||
data-testid={createNameTestId}
|
||||
disabled={isCreating}
|
||||
onChange={(event) => form.changeName(event.target.value)}
|
||||
placeholder={namePlaceholder}
|
||||
ref={form.inputRef}
|
||||
spellCheck={false}
|
||||
value={draftName}
|
||||
value={form.draftName}
|
||||
/>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-stream-description"
|
||||
disabled={isCreatingChannel}
|
||||
onChange={(event) => onChangeDescription(event.target.value)}
|
||||
placeholder="What this stream is for"
|
||||
value={draftDescription}
|
||||
data-testid={createDescriptionTestId}
|
||||
disabled={isCreating}
|
||||
onChange={(event) => form.changeDescription(event.target.value)}
|
||||
placeholder={descriptionPlaceholder}
|
||||
value={form.draftDescription}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={isCreatingChannel || draftName.trim().length === 0}
|
||||
disabled={isCreating || form.draftName.trim().length === 0}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{isCreatingChannel ? "Creating..." : "Create"}
|
||||
{isCreating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isCreatingChannel}
|
||||
onClick={onCancelCreate}
|
||||
disabled={isCreating}
|
||||
onClick={form.cancel}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -164,14 +320,14 @@ function StreamsSection({
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{createErrorMessage ? (
|
||||
<p className="text-sm text-destructive">{createErrorMessage}</p>
|
||||
{form.errorMessage ? (
|
||||
<p className="text-sm text-destructive">{form.errorMessage}</p>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<SidebarMenu data-testid="stream-list">
|
||||
<SidebarMenu data-testid={listTestId}>
|
||||
{items.map((channel) => (
|
||||
<SidebarMenuItem key={channel.id}>
|
||||
<ChannelMenuButton
|
||||
@@ -189,130 +345,9 @@ function StreamsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function ForumsSection({
|
||||
items,
|
||||
isCreateOpen,
|
||||
isCreatingForum,
|
||||
draftName,
|
||||
draftDescription,
|
||||
createInputRef,
|
||||
createErrorMessage,
|
||||
onToggleCreate,
|
||||
onChangeName,
|
||||
onChangeDescription,
|
||||
onCreateForum,
|
||||
onCancelCreate,
|
||||
onSelectChannel,
|
||||
isActiveChannel,
|
||||
selectedChannelId,
|
||||
unreadChannelIds,
|
||||
}: {
|
||||
items: Channel[];
|
||||
isCreateOpen: boolean;
|
||||
isCreatingForum: boolean;
|
||||
draftName: string;
|
||||
draftDescription: string;
|
||||
createInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
createErrorMessage?: string;
|
||||
onToggleCreate: () => void;
|
||||
onChangeName: (value: string) => void;
|
||||
onChangeDescription: (value: string) => void;
|
||||
onCreateForum: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
onCancelCreate: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
isActiveChannel: boolean;
|
||||
selectedChannelId: string | null;
|
||||
unreadChannelIds: Set<string>;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Forums</SidebarGroupLabel>
|
||||
<SidebarGroupAction
|
||||
aria-expanded={isCreateOpen}
|
||||
aria-label={isCreateOpen ? "Close new forum form" : "New forum"}
|
||||
className="top-3 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
|
||||
onClick={onToggleCreate}
|
||||
type="button"
|
||||
>
|
||||
<Plus
|
||||
className={
|
||||
isCreateOpen
|
||||
? "rotate-45 transition-transform"
|
||||
: "transition-transform"
|
||||
}
|
||||
/>
|
||||
</SidebarGroupAction>
|
||||
<SidebarGroupContent>
|
||||
{isCreateOpen ? (
|
||||
<form
|
||||
className="mb-2 space-y-2 rounded-lg border border-sidebar-border/70 bg-sidebar-accent/60 p-2"
|
||||
data-testid="create-forum-form"
|
||||
onSubmit={onCreateForum}
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-forum-name"
|
||||
disabled={isCreatingForum}
|
||||
onChange={(event) => onChangeName(event.target.value)}
|
||||
placeholder="design-discussions"
|
||||
ref={createInputRef}
|
||||
spellCheck={false}
|
||||
value={draftName}
|
||||
/>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-forum-description"
|
||||
disabled={isCreatingForum}
|
||||
onChange={(event) => onChangeDescription(event.target.value)}
|
||||
placeholder="What this forum is for"
|
||||
value={draftDescription}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={isCreatingForum || draftName.trim().length === 0}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{isCreatingForum ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isCreatingForum}
|
||||
onClick={onCancelCreate}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{createErrorMessage ? (
|
||||
<p className="text-sm text-destructive">{createErrorMessage}</p>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<SidebarMenu data-testid="forum-list">
|
||||
{items.map((channel) => (
|
||||
<SidebarMenuItem key={channel.id}>
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
isActive={isActiveChannel && selectedChannelId === channel.id}
|
||||
onSelectChannel={onSelectChannel}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
) : null}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppSidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AppSidebar({
|
||||
channels,
|
||||
@@ -332,6 +367,7 @@ export function AppSidebar({
|
||||
onCreateChannel,
|
||||
onCreateForum,
|
||||
onOpenBrowseChannels,
|
||||
onOpenBrowseForums,
|
||||
onOpenSearch,
|
||||
onOpenDm,
|
||||
onSelectAgents,
|
||||
@@ -340,21 +376,11 @@ export function AppSidebar({
|
||||
onSelectSettings,
|
||||
}: AppSidebarProps) {
|
||||
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||
const [isForumCreateOpen, setIsForumCreateOpen] = React.useState(false);
|
||||
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
||||
const [draftName, setDraftName] = React.useState("");
|
||||
const [draftDescription, setDraftDescription] = React.useState("");
|
||||
const [forumDraftName, setForumDraftName] = React.useState("");
|
||||
const [forumDraftDescription, setForumDraftDescription] = React.useState("");
|
||||
const [createErrorMessage, setCreateErrorMessage] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [forumCreateErrorMessage, setForumCreateErrorMessage] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
const createInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const forumCreateInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const streamForm = useCreateForm(onCreateChannel, "stream");
|
||||
const forumForm = useCreateForm(onCreateForum, "forum");
|
||||
|
||||
const streamChannels = channels.filter(
|
||||
(channel) => channel.channelType === "stream",
|
||||
);
|
||||
@@ -380,76 +406,6 @@ export function AppSidebar({
|
||||
fallbackDisplayName?.trim() ||
|
||||
"Current identity";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isCreateOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
createInputRef.current?.focus();
|
||||
}, [isCreateOpen]);
|
||||
|
||||
async function handleCreateChannel(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = draftName.trim();
|
||||
const description = draftDescription.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateErrorMessage(undefined);
|
||||
|
||||
try {
|
||||
await onCreateChannel({
|
||||
name,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
setDraftName("");
|
||||
setDraftDescription("");
|
||||
setIsCreateOpen(false);
|
||||
} catch (error) {
|
||||
setCreateErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to create stream.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateForum(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = forumDraftName.trim();
|
||||
const description = forumDraftDescription.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForumCreateErrorMessage(undefined);
|
||||
|
||||
try {
|
||||
await onCreateForum({
|
||||
name,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
setForumDraftName("");
|
||||
setForumDraftDescription("");
|
||||
setIsForumCreateOpen(false);
|
||||
} catch (error) {
|
||||
setForumCreateErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to create forum.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isForumCreateOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
forumCreateInputRef.current?.focus();
|
||||
}, [isForumCreateOpen]);
|
||||
|
||||
function handleDragPointerDown(e: React.PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -549,86 +505,47 @@ export function AppSidebar({
|
||||
|
||||
{!isLoading ? (
|
||||
<>
|
||||
<StreamsSection
|
||||
createErrorMessage={createErrorMessage}
|
||||
createInputRef={createInputRef}
|
||||
draftDescription={draftDescription}
|
||||
draftName={draftName}
|
||||
isCreateOpen={isCreateOpen}
|
||||
isCreatingChannel={isCreatingChannel}
|
||||
<ChannelGroupSection
|
||||
browseAriaLabel="Browse channels"
|
||||
browseTestId="browse-channels"
|
||||
closeAriaLabel="Close new stream form"
|
||||
createAriaLabel="Create a stream"
|
||||
createFormTestId="create-stream-form"
|
||||
createNameTestId="create-stream-name"
|
||||
createDescriptionTestId="create-stream-description"
|
||||
descriptionPlaceholder="What this stream is for"
|
||||
form={streamForm}
|
||||
groupClassName="pt-1"
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
isCreating={isCreatingChannel}
|
||||
items={streamChannels}
|
||||
onCancelCreate={() => {
|
||||
setCreateErrorMessage(undefined);
|
||||
setDraftName("");
|
||||
setDraftDescription("");
|
||||
setIsCreateOpen(false);
|
||||
}}
|
||||
onChangeDescription={(value) => {
|
||||
setCreateErrorMessage(undefined);
|
||||
setDraftDescription(value);
|
||||
}}
|
||||
onChangeName={(value) => {
|
||||
setCreateErrorMessage(undefined);
|
||||
setDraftName(value);
|
||||
}}
|
||||
onCreateChannel={(event) => {
|
||||
void handleCreateChannel(event);
|
||||
}}
|
||||
listTestId="stream-list"
|
||||
namePlaceholder="release-notes"
|
||||
onBrowse={onOpenBrowseChannels}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onToggleCreate={() => {
|
||||
setCreateErrorMessage(undefined);
|
||||
setIsCreateOpen((current) => !current);
|
||||
}}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Channels"
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
<SidebarMenu className="px-2">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="text-sidebar-foreground/60 hover:text-sidebar-foreground"
|
||||
data-testid="browse-channels"
|
||||
onClick={onOpenBrowseChannels}
|
||||
tooltip="Browse channels"
|
||||
type="button"
|
||||
>
|
||||
<Compass className="h-4 w-4" />
|
||||
<span>Browse channels</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<ForumsSection
|
||||
createErrorMessage={forumCreateErrorMessage}
|
||||
createInputRef={forumCreateInputRef}
|
||||
draftDescription={forumDraftDescription}
|
||||
draftName={forumDraftName}
|
||||
<ChannelGroupSection
|
||||
browseAriaLabel="Browse forums"
|
||||
browseTestId="browse-forums"
|
||||
closeAriaLabel="Close new forum form"
|
||||
createAriaLabel="New forum"
|
||||
createFormTestId="create-forum-form"
|
||||
createNameTestId="create-forum-name"
|
||||
createDescriptionTestId="create-forum-description"
|
||||
descriptionPlaceholder="What this forum is for"
|
||||
form={forumForm}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
isCreateOpen={isForumCreateOpen}
|
||||
isCreatingForum={isCreatingForum}
|
||||
isCreating={isCreatingForum}
|
||||
items={forumChannels}
|
||||
onCancelCreate={() => {
|
||||
setForumCreateErrorMessage(undefined);
|
||||
setForumDraftName("");
|
||||
setForumDraftDescription("");
|
||||
setIsForumCreateOpen(false);
|
||||
}}
|
||||
onChangeDescription={(value) => {
|
||||
setForumCreateErrorMessage(undefined);
|
||||
setForumDraftDescription(value);
|
||||
}}
|
||||
onChangeName={(value) => {
|
||||
setForumCreateErrorMessage(undefined);
|
||||
setForumDraftName(value);
|
||||
}}
|
||||
onCreateForum={(event) => {
|
||||
void handleCreateForum(event);
|
||||
}}
|
||||
listTestId="forum-list"
|
||||
namePlaceholder="design-discussions"
|
||||
onBrowse={onOpenBrowseForums}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onToggleCreate={() => {
|
||||
setForumCreateErrorMessage(undefined);
|
||||
setIsForumCreateOpen((current) => !current);
|
||||
}}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Forums"
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
<SidebarSection
|
||||
@@ -647,7 +564,6 @@ export function AppSidebar({
|
||||
</SidebarGroupAction>
|
||||
}
|
||||
dmParticipantsByChannelId={dmParticipantsByChannelId}
|
||||
emptyState="No direct messages yet."
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
items={directMessages}
|
||||
channelLabels={dmChannelLabels}
|
||||
@@ -661,12 +577,6 @@ export function AppSidebar({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isLoading && channels.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-sidebar-foreground/70">
|
||||
No channels available yet.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
|
||||
Reference in New Issue
Block a user