feat(desktop): create channels in sections (#1996)

Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Bradley Axen
2026-07-16 16:10:26 -07:00
committed by GitHub
parent f06b59ff13
commit 9b6497402f
5 changed files with 183 additions and 38 deletions

View File

@@ -4,14 +4,12 @@ import { Outlet, useLocation } from "@tanstack/react-router";
import { deriveShellRoute } from "@/app/AppShell.helpers";
import { AppShellProvider } from "@/app/AppShellContext";
import {
AppShellOverlays,
type BrowseDialogType,
} from "@/app/AppShellOverlays";
import { AppShellOverlays } from "@/app/AppShellOverlays";
import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog";
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications";
@@ -110,8 +108,6 @@ export function AppShell() {
null,
);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false);
const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false);
@@ -415,27 +411,22 @@ export function AppShell() {
[unfollowThread, muteThread],
);
const createChannelMutation = useCreateChannelMutation();
const createForumMutation = useCreateChannelMutation();
const createChannelMutation = useCreateChannelMutation(),
createForumMutation = useCreateChannelMutation();
const { applyCanvas, applyAgents } = useApplyTemplate();
const openDmMutation = useOpenDmMutation();
const hideDmMutation = useHideDmMutation();
const handleOpenBrowseChannels = React.useCallback(() => {
setBrowseDialogType("stream");
void refetchChannels();
}, [refetchChannels]);
const {
browseDialogType,
openBrowseChannels: handleOpenBrowseChannels,
onBrowseDialogOpenChange: handleBrowseDialogOpenChange,
getCreateSuccess,
} = useChannelBrowserDialog(() => void refetchChannels());
const handleOpenSearch = React.useCallback(() => {
setSearchFocusRequest((request) => request + 1);
void refetchChannels();
}, [refetchChannels]);
const handleBrowseDialogOpenChange = React.useCallback((open: boolean) => {
if (!open) {
setBrowseDialogType(null);
}
}, []);
const handleBrowseChannelJoin = React.useCallback(
async (channelId: string) => {
await joinChannel(channelId);
@@ -445,19 +436,22 @@ export function AppShell() {
);
const handleCreateChannel = React.useCallback(
async ({
description,
name,
visibility,
ttlSeconds,
templateId,
}: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => {
async (
{
description,
name,
visibility,
ttlSeconds,
templateId,
}: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
},
onCreated?: (channelId: string) => void,
) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
@@ -468,6 +462,7 @@ export function AppShell() {
await applyCanvas(templateId, createdChannel.id, name);
await goChannel(createdChannel.id);
onCreated?.(createdChannel.id);
void applyAgents(templateId, createdChannel.id);
},
[applyAgents, applyCanvas, createChannelMutation, goChannel],
@@ -515,10 +510,15 @@ export function AppShell() {
if (browseDialogType === "forum") {
await handleCreateForum(input);
} else {
await handleCreateChannel(input);
await handleCreateChannel(input, getCreateSuccess() ?? undefined);
}
},
[browseDialogType, handleCreateChannel, handleCreateForum],
[
browseDialogType,
handleCreateChannel,
handleCreateForum,
getCreateSuccess,
],
);
const handleHideDm = React.useCallback(

View File

@@ -0,0 +1,39 @@
import * as React from "react";
import type { BrowseDialogType } from "@/app/AppShellOverlays";
type CreatedCallback = (channelId: string) => void;
export function useChannelBrowserDialog(onOpen: () => void) {
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const createSuccessRef = React.useRef<CreatedCallback | null>(null);
const openBrowseChannels = React.useCallback(
(onCreated?: CreatedCallback) => {
createSuccessRef.current = onCreated ?? null;
setBrowseDialogType("stream");
onOpen();
},
[onOpen],
);
const onBrowseDialogOpenChange = React.useCallback((open: boolean) => {
if (!open) {
createSuccessRef.current = null;
setBrowseDialogType(null);
}
}, []);
const getCreateSuccess = React.useCallback(
() => createSuccessRef.current,
[],
);
return {
browseDialogType,
openBrowseChannels,
onBrowseDialogOpenChange,
getCreateSuccess,
};
}

View File

@@ -128,7 +128,7 @@ type AppSidebarProps = {
lastMessageAt: string | null | undefined,
) => void;
onMarkAllChannelsRead: () => void;
onBrowseChannels?: () => void;
onBrowseChannels?: (onCreated?: (channelId: string) => void) => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onUpdateCommunity: (
id: string,
@@ -530,6 +530,13 @@ export function AppSidebar({
openCreateDialog("stream");
}, [onCreateChannelOpenChange, openCreateDialog]);
const handleCreateChannelInSection = React.useCallback(
(sectionId: string) => {
onBrowseChannels?.((channelId) => assignChannel(channelId, sectionId));
},
[assignChannel, onBrowseChannels],
);
return (
<Sidebar
className="!border-r-0"
@@ -667,6 +674,9 @@ export function AppSidebar({
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
onCreateChannel={() =>
handleCreateChannelInSection(section.id)
}
onRenameSection={() => setRenameSectionTarget(section)}
onDeleteSection={() => setDeleteSectionTarget(section)}
onMoveSectionUp={() => moveSectionUp(section.id)}

View File

@@ -557,6 +557,7 @@ export function CustomChannelSection({
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
onCreateChannel,
onRenameSection,
onDeleteSection,
onMoveSectionUp,
@@ -595,6 +596,7 @@ export function CustomChannelSection({
onAssignChannel: (channelId: string, sectionId: string) => void;
onUnassignChannel: (channelId: string) => void;
onCreateSectionForChannel: (channelId: string) => void;
onCreateChannel: () => void;
onRenameSection: () => void;
onDeleteSection: () => void;
onMoveSectionUp: () => void;
@@ -664,6 +666,11 @@ export function CustomChannelSection({
</button>
</SidebarGroupLabel>
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<SectionQuickAction
label={`Add channel to ${section.name}`}
onClick={onCreateChannel}
testId={`section-actions-${section.id}-quick-create`}
/>
<SectionActionsMenu
sectionLabel={section.name}
testId={`section-actions-${section.id}`}

View File

@@ -1,9 +1,29 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge, openChannelBrowser } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
const MOCK_PUBKEY = "deadbeef".repeat(8);
const CUSTOM_SECTION = { id: "sec-projects", name: "Projects", order: 0 };
async function seedCustomSection(page: Page) {
await page.addInitScript(
({ pubkey, section }) => {
window.localStorage.setItem(
`buzz-channel-sections.v1:${pubkey}`,
JSON.stringify({ version: 1, sections: [section], assignments: {} }),
);
},
{ pubkey: MOCK_PUBKEY, section: CUSTOM_SECTION },
);
}
test.beforeEach(async ({ page }, testInfo) => {
await installMockBridge(
page,
testInfo.title.includes("failed section create")
? { createChannelErrors: ["Create failed"] }
: undefined,
);
});
test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
@@ -172,6 +192,75 @@ test("sidebar add-channel button opens the browser", async ({ page }) => {
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
});
test("custom section add button creates directly into that section", async ({
page,
}) => {
await seedCustomSection(page);
await page.goto("/");
const addButton = page.getByTestId(
`section-actions-${CUSTOM_SECTION.id}-quick-create`,
);
await expect(addButton).toHaveAccessibleName("Add channel to Projects");
await addButton.click();
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
const channelName = `section-created-${Date.now()}`;
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(
page.getByTestId(`section-title-${CUSTOM_SECTION.id}`),
).toBeVisible();
await expect(page.getByTestId(`channel-${channelName}`)).toBeVisible();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
});
test("canceling section create does not affect the next global create", async ({
page,
}) => {
await seedCustomSection(page);
await page.goto("/");
await page
.getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`)
.click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await page.getByTestId("section-actions-channels-quick-create").click();
const channelName = `global-after-cancel-${Date.now()}`;
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
});
test("failed section create retry still assigns to the section", async ({
page,
}) => {
await seedCustomSection(page);
await page.goto("/");
await page
.getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`)
.click();
const channelName = `section-retry-${Date.now()}`;
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByText("Create failed")).toBeVisible();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId(`channel-${channelName}`)).toBeVisible();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
});
test("create affordance is visible on open before typing", async ({ page }) => {
await page.goto("/");