mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
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:
@@ -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(
|
||||
|
||||
39
desktop/src/app/useChannelBrowserDialog.ts
Normal file
39
desktop/src/app/useChannelBrowserDialog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user