+ Keep agents you address selected for future messages in the same + channel or thread. Remove them from the composer at any time. +
+{policyError}
+ ) : null} + {error ? ({error}
) : null} +`).
- *
- * The code is not validated here — validation happens in the desktop app when
- * the invite is claimed against `POST /api/invites/claim`, signed by the
- * joining key. This page only hands the code off via the `buzz://join` deep
- * link (or tells the visitor where to get the app first).
- */
+type PolicyDocument = { title: string; markdown: string };
+
+/** Landing page for a community invite link (`/invite/`). */
export function InvitePage({ code }: { code: string }) {
const relay = relayWsUrl();
const host = relay.replace(/^wss?:\/\//, "");
- const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
+ const [policy, setPolicy] = React.useState(
+ undefined,
+ );
+ const [document, setDocument] = React.useState(null);
+ const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+ const [opening, setOpening] = React.useState(false);
+
+ React.useEffect(() => {
+ fetch("/api/join-policy")
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const config = (await response.json()) as { policy?: JoinPolicy };
+ setPolicy(config.policy ?? null);
+ })
+ .catch(() => setPolicy(undefined));
+ }, []);
+
+ const openInvite = async () => {
+ setOpening(true);
+ try {
+ let receipt: string | undefined;
+ if (policy) {
+ const response = await fetch("/api/invites/accept-policy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ code,
+ policy_version: policy.version,
+ age_confirmed: ageConfirmed,
+ }),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ receipt = ((await response.json()) as { receipt: string }).receipt;
+ }
+ const query = new URLSearchParams({ relay, code });
+ if (receipt) query.set("policy_receipt", receipt);
+ window.location.href = `buzz://join?${query.toString()}`;
+ } finally {
+ setOpening(false);
+ }
+ };
+
+ const disabled =
+ policy === undefined ||
+ opening ||
+ Boolean(policy?.age_attestation_required && !ageConfirmed);
+ const showDocument = (title: string, markdown: string) =>
+ setDocument({ title, markdown });
return (
-
-
-
-
-
- You're invited to join
-
- {host}
-
-
-
+
+
- Accept invite in Buzz
-
-
+
+
+
+ You're invited to
+
+ {host}
-
+ {policy?.age_attestation_required && (
+
+ )}
+
+ {policy === null ? (
+
+
+ Accept invite in Buzz
+
+
+ ) : (
+
+ Accept invite in Buzz
+
+ )}
+
+ {policy && (policy.terms_markdown || policy.privacy_markdown) && (
+
+ By proceeding you agree to the Buzz{" "}
+ {policy.terms_markdown && (
+
+ showDocument(
+ "Terms of Service",
+ policy.terms_markdown ?? "",
+ )
+ }
+ >
+ Terms of Service
+
+ )}
+ {policy.terms_markdown && policy.privacy_markdown && " and "}
+ {policy.privacy_markdown && (
+
+ showDocument(
+ "Privacy Policy",
+ policy.privacy_markdown ?? "",
+ )
+ }
+ >
+ Privacy Policy
+
+ )}
+ .
+
+ )}
+
+
+
+ {document && (
+ {
+ if (event.currentTarget === event.target) setDocument(null);
+ }}
+ >
+
+
+ {document.title}
+ setDocument(null)}
+ >
+ ×
+
+
+
+
+ {document.markdown}
+
+
+
+
+ )}
);
}
diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts
index 183197dac..3e897b12f 100644
--- a/web/tests/e2e/smoke.spec.ts
+++ b/web/tests/e2e/smoke.spec.ts
@@ -9,3 +9,67 @@ test("home page shows repositories section", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("Repositories")).toBeVisible();
});
+
+test("invite requires age and legal consent before opening Buzz", async ({
+ page,
+}) => {
+ await page.route("**/api/join-policy", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ policy: {
+ terms_markdown: "# Terms",
+ privacy_markdown: "# Privacy",
+ age_attestation_required: true,
+ version: "policy-v1",
+ },
+ }),
+ });
+ });
+ await page.goto("/invite/demo-code");
+
+ const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
+ const agreementConfirmation = page.getByLabel(
+ "I agree to the Buzz Terms of Service and Privacy Policy.",
+ );
+ const acceptInvite = page.getByRole("button", {
+ name: "Accept invite in Buzz",
+ });
+
+ await expect(ageConfirmation).toBeVisible();
+ await expect(agreementConfirmation).toBeVisible();
+ await expect(acceptInvite).toBeDisabled();
+
+ const termsLink = page.getByRole("button", { name: "Terms of Service" });
+ const privacyLink = page.getByRole("button", { name: "Privacy Policy" });
+ await expect(termsLink).toHaveCSS("text-decoration-line", "none");
+ await expect(privacyLink).toHaveCSS("text-decoration-line", "none");
+ await termsLink.hover();
+ await expect(termsLink).toHaveCSS("text-decoration-line", "underline");
+ await page.mouse.move(0, 0);
+ await privacyLink.hover();
+ await expect(privacyLink).toHaveCSS("text-decoration-line", "underline");
+
+ await page
+ .locator("label")
+ .filter({ hasText: "I am 18 years of age or older." })
+ .click();
+ await expect(ageConfirmation).toBeChecked();
+ await expect(acceptInvite).toBeDisabled();
+ await page
+ .locator("label")
+ .filter({
+ hasText: "I agree to the Buzz Terms of Service and Privacy Policy.",
+ })
+ .click({ position: { x: 8, y: 8 } });
+ await expect(agreementConfirmation).toBeChecked();
+ await expect(acceptInvite).toBeEnabled();
+
+ const consentBox = await page
+ .getByTestId("invite-join-policy-notice")
+ .boundingBox();
+ const acceptButtonBox = await acceptInvite.boundingBox();
+ expect(consentBox?.y).toBeLessThan(acceptButtonBox?.y ?? 0);
+ expect(consentBox?.width).toBe(acceptButtonBox?.width);
+});
From 3dd236eb6fd7c0dd86a9174d2852bc9ae9861912 Mon Sep 17 00:00:00 2001
From: tulsi
Date: Thu, 16 Jul 2026 14:01:53 -0400
Subject: [PATCH 04/12] unify channel add + search into one entry point (#1964)
---
desktop/playwright.config.ts | 1 +
desktop/src/app/AppShell.tsx | 134 ++--
desktop/src/app/AppShellOverlays.tsx | 7 +
.../channels/lib/channelSearchScore.test.mjs | 113 +++
.../channels/lib/channelSearchScore.ts | 119 ++++
.../channels/ui/ChannelBrowserDialog.tsx | 670 ++++++++++++------
.../sidebar/lib/useCreateChannelForm.ts | 214 ++++++
.../src/features/sidebar/ui/AppSidebar.tsx | 6 +-
.../sidebar/ui/CreateChannelDialog.tsx | 385 +---------
.../sidebar/ui/CreateChannelFormFields.tsx | 301 ++++++++
.../sidebar/ui/CustomChannelSection.tsx | 16 +-
.../tests/e2e/channel-add-screenshots.spec.ts | 46 ++
desktop/tests/e2e/channel-browser.spec.ts | 191 +++++
desktop/tests/helpers/bridge.ts | 22 +-
14 files changed, 1598 insertions(+), 627 deletions(-)
create mode 100644 desktop/src/features/channels/lib/channelSearchScore.test.mjs
create mode 100644 desktop/src/features/channels/lib/channelSearchScore.ts
create mode 100644 desktop/src/features/sidebar/lib/useCreateChannelForm.ts
create mode 100644 desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx
create mode 100644 desktop/tests/e2e/channel-add-screenshots.spec.ts
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 874e7e189..805fcb289 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -25,6 +25,7 @@ export default defineConfig({
"**/channel-shared-header-backdrop.spec.ts",
"**/badge.spec.ts",
"**/channel-browser.spec.ts",
+ "**/channel-add-screenshots.spec.ts",
"**/messaging.spec.ts",
"**/custom-emoji.spec.ts",
"**/profile-custom-emoji-status.spec.ts",
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index b4e95344e..eadb84d9d 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -80,7 +80,7 @@ import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock";
import { joinChannel } from "@/shared/api/tauri";
-import type { SearchHit } from "@/shared/api/types";
+import type { ChannelVisibility, SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
@@ -441,6 +441,83 @@ export function AppShell() {
[queryClient],
);
+ const handleCreateChannel = React.useCallback(
+ async ({
+ description,
+ name,
+ visibility,
+ ttlSeconds,
+ templateId,
+ }: {
+ name: string;
+ description?: string;
+ visibility: ChannelVisibility;
+ ttlSeconds?: number;
+ templateId?: string;
+ }) => {
+ 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);
+ },
+ [applyAgents, applyCanvas, createChannelMutation, goChannel],
+ );
+
+ const handleCreateForum = React.useCallback(
+ async ({
+ description,
+ name,
+ visibility,
+ ttlSeconds,
+ templateId,
+ }: {
+ name: string;
+ description?: string;
+ visibility: ChannelVisibility;
+ ttlSeconds?: number;
+ templateId?: string;
+ }) => {
+ 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);
+ },
+ [applyAgents, applyCanvas, createForumMutation, goChannel],
+ );
+
+ // The channel browser can create either a stream or a forum depending on
+ // which section opened it. Route to the matching handler.
+ const handleBrowseChannelCreate = React.useCallback(
+ async (input: {
+ name: string;
+ description?: string;
+ visibility: ChannelVisibility;
+ ttlSeconds?: number;
+ templateId?: string;
+ }) => {
+ if (browseDialogType === "forum") {
+ await handleCreateForum(input);
+ } else {
+ await handleCreateChannel(input);
+ }
+ },
+ [browseDialogType, handleCreateChannel, handleCreateForum],
+ );
+
const handleHideDm = React.useCallback(
async (channelId: string) => {
try {
@@ -779,54 +856,8 @@ export function AppShell() {
onCreateAgent={() => requestOpenCreateAgent()}
selfPresenceStatus={presenceSession.currentStatus}
communities={communitiesHook.communities}
- 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);
- }}
+ onCreateChannel={handleCreateChannel}
+ onCreateForum={handleCreateForum}
onHideDm={handleHideDm}
onMarkAllChannelsRead={markAllChannelsRead}
onMarkChannelRead={markChannelRead}
@@ -913,7 +944,12 @@ export function AppShell() {
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
+ isCreatingBrowseChannel={
+ createChannelMutation.isPending ||
+ createForumMutation.isPending
+ }
onBrowseChannelJoin={handleBrowseChannelJoin}
+ onBrowseChannelCreate={handleBrowseChannelCreate}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={(open) => {
setIsChannelManagementOpen(open);
diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx
index 514492a29..5311a2933 100644
--- a/desktop/src/app/AppShellOverlays.tsx
+++ b/desktop/src/app/AppShellOverlays.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import type { Channel } from "@/shared/api/types";
+import type { CreateChannelInput } from "@/features/sidebar/lib/useCreateChannelForm";
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
const ChannelBrowserDialog = React.lazy(async () => {
@@ -21,7 +22,9 @@ type AppShellOverlaysProps = {
channels: Channel[];
currentPubkey?: string;
isChannelManagementOpen: boolean;
+ isCreatingBrowseChannel?: boolean;
onBrowseChannelJoin: (channelId: string) => Promise;
+ onBrowseChannelCreate?: (input: CreateChannelInput) => Promise;
onBrowseDialogOpenChange: (open: boolean) => void;
onChannelManagementOpenChange: (open: boolean) => void;
onDeleteActiveChannel: () => void;
@@ -34,7 +37,9 @@ export function AppShellOverlays({
channels,
currentPubkey,
isChannelManagementOpen,
+ isCreatingBrowseChannel,
onBrowseChannelJoin,
+ onBrowseChannelCreate,
onBrowseDialogOpenChange,
onChannelManagementOpenChange,
onDeleteActiveChannel,
@@ -67,6 +72,8 @@ export function AppShellOverlays({
{
+ assert.equal(scoreChannelName("release-notes", ""), 0);
+});
+
+test("scoreChannelName: exact name beats prefix beats word matches", () => {
+ const exact = scoreChannelName("general", "general");
+ const prefix = scoreChannelName("general-chat", "general");
+ const wordExact = scoreChannelName("team-general", "general");
+ assert.ok(exact < prefix, "exact should rank above prefix");
+ assert.ok(prefix < wordExact, "prefix should rank above later-word match");
+});
+
+test("scoreChannelName: matches a later whole word", () => {
+ // "notes" is the second word of "release-notes"
+ assert.notEqual(scoreChannelName("release-notes", "notes"), null);
+});
+
+test("scoreChannelName: matches a later word prefix", () => {
+ assert.notEqual(scoreChannelName("release-notes", "not"), null);
+});
+
+test("scoreChannelName: plain substring still matches", () => {
+ assert.notEqual(scoreChannelName("release-notes", "ease"), null);
+});
+
+test("scoreChannelName: collapsing separators matches 'releasenotes'", () => {
+ // The core pain point: dropping the dash should still find the channel.
+ const score = scoreChannelName("release-notes", "releasenotes");
+ assert.notEqual(score, null);
+});
+
+test("scoreChannelName: partial across the separator ('releasenot')", () => {
+ assert.notEqual(scoreChannelName("release-notes", "releasenot"), null);
+});
+
+test("scoreChannelName: subsequence matches 'reln'", () => {
+ // r-e-l...n appears in order inside "release-notes"
+ assert.notEqual(scoreChannelName("release-notes", "reln"), null);
+});
+
+test("scoreChannelName: subsequence works across underscores and dots", () => {
+ assert.notEqual(scoreChannelName("build_and_deploy", "bd"), null);
+ assert.notEqual(scoreChannelName("v1.2.release", "vrel"), null);
+});
+
+test("scoreChannelName: single-char subsequence noise is rejected", () => {
+ // A lone char that isn't a prefix/substring should NOT fuzzy-match, or every
+ // channel containing that letter would show up.
+ assert.equal(scoreChannelName("release-notes", "z"), null);
+ // "x" doesn't appear at all
+ assert.equal(scoreChannelName("release-notes", "x"), null);
+});
+
+test("scoreChannelName: unrelated query returns null", () => {
+ assert.equal(scoreChannelName("release-notes", "budget"), null);
+});
+
+test("scoreChannelName: subsequence requires correct order", () => {
+ // "sn" — 's' then 'n' — is NOT in order in "release-notes" (n comes... yes it
+ // is: relea-s-e-n-otes). Use a genuinely out-of-order example instead.
+ assert.equal(scoreChannelName("abc", "ca"), null);
+});
+
+test("scoreChannelName: better matches score lower than fuzzier ones", () => {
+ const prefix = scoreChannelName("release-notes", "release");
+ const collapsed = scoreChannelName("release-notes", "releasenotes");
+ const subsequence = scoreChannelName("release-notes", "reln");
+ assert.ok(prefix < collapsed, "prefix beats collapsed-separator match");
+ assert.ok(collapsed < subsequence, "collapsed beats subsequence match");
+});
+
+test("scoreChannelMatch: name match outranks description match", () => {
+ const nameHit = scoreChannelMatch(
+ { name: "release-notes", description: "" },
+ "release",
+ );
+ const descHit = scoreChannelMatch(
+ { name: "random", description: "release coordination" },
+ "release",
+ );
+ assert.ok(
+ nameHit !== null && descHit !== null && nameHit < descHit,
+ "a name match should rank above a description-only match",
+ );
+});
+
+test("scoreChannelMatch: description only does plain substring, not fuzzy", () => {
+ // "reln" should not fuzzy-match the description.
+ assert.equal(
+ scoreChannelMatch({ name: "random", description: "release notes" }, "reln"),
+ null,
+ );
+ // But a real substring in the description matches.
+ assert.notEqual(
+ scoreChannelMatch(
+ { name: "random", description: "release notes" },
+ "notes",
+ ),
+ null,
+ );
+});
+
+test("scoreChannelMatch: no match anywhere returns null", () => {
+ assert.equal(
+ scoreChannelMatch({ name: "general", description: "chat" }, "budget"),
+ null,
+ );
+});
diff --git a/desktop/src/features/channels/lib/channelSearchScore.ts b/desktop/src/features/channels/lib/channelSearchScore.ts
new file mode 100644
index 000000000..3f27a8fbc
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelSearchScore.ts
@@ -0,0 +1,119 @@
+/**
+ * Lightweight fuzzy matching for the channel browser search box.
+ *
+ * Mirrors the philosophy of `mentionRanking.ts`: cheap, dependency-free,
+ * separator-aware scoring — no Levenshtein / typo-tolerance (which reorders
+ * results unpredictably and hides the channel a user can plainly see).
+ *
+ * The one thing plain substring search gets wrong is contiguity across
+ * separators: typing `releasenotes` or `reln` should still find
+ * `release-notes`. We fix that with two extra passes on top of substring:
+ *
+ * 1. word-boundary tokens (split on space/`-`/`_`), so multi-word names match
+ * when the separators are dropped or a later word is typed, and
+ * 2. an in-order subsequence check, so `reln` matches `release-notes`.
+ *
+ * Lower score === better match. `null` means "no match".
+ */
+
+/** Separators that delimit words in a channel name/description. */
+const WORD_SEPARATORS = /[\s\-_./]+/;
+
+// Score bands. Kept as named steps so the intent (and ordering) is legible.
+const SCORE_EXACT = 0;
+const SCORE_PREFIX = 1;
+const SCORE_WORD_EXACT = 2;
+const SCORE_WORD_PREFIX = 3;
+const SCORE_SUBSTRING = 4;
+const SCORE_COLLAPSED_SEPARATORS = 5;
+const SCORE_SUBSEQUENCE = 6;
+const SCORE_DESCRIPTION = 7;
+
+/** Strip separators so `release-notes` and `releasenotes` compare equal. */
+function collapseSeparators(value: string): string {
+ return value.replace(/[\s\-_./]+/g, "");
+}
+
+/**
+ * Whether every char of `query` appears in `text` in order (not necessarily
+ * contiguously). e.g. `reln` is a subsequence of `release-notes`.
+ */
+function isSubsequence(query: string, text: string): boolean {
+ if (query.length === 0) return true;
+ let queryIndex = 0;
+ for (const char of text) {
+ if (char === query[queryIndex]) {
+ queryIndex += 1;
+ if (queryIndex === query.length) return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Score how well `name` matches `lowerQuery`. Returns the best (lowest) band,
+ * or `null` if the name doesn't match at all. `lowerQuery` must already be
+ * lowercased and trimmed.
+ */
+export function scoreChannelName(
+ name: string,
+ lowerQuery: string,
+): number | null {
+ if (lowerQuery.length === 0) return SCORE_EXACT;
+
+ const lower = name.toLowerCase();
+
+ if (lower === lowerQuery) return SCORE_EXACT;
+ if (lower.startsWith(lowerQuery)) return SCORE_PREFIX;
+
+ const words = lower.split(WORD_SEPARATORS).filter(Boolean);
+ if (words.some((word) => word === lowerQuery)) return SCORE_WORD_EXACT;
+ if (words.some((word) => word.startsWith(lowerQuery))) {
+ return SCORE_WORD_PREFIX;
+ }
+
+ if (lower.includes(lowerQuery)) return SCORE_SUBSTRING;
+
+ // `releasenotes` → matches `release-notes` once separators are removed.
+ const collapsedName = collapseSeparators(lower);
+ const collapsedQuery = collapseSeparators(lowerQuery);
+ if (collapsedQuery.length > 0 && collapsedName.includes(collapsedQuery)) {
+ return SCORE_COLLAPSED_SEPARATORS;
+ }
+
+ // `reln` → matches `release-notes` as an in-order subsequence. Guard against
+ // 1-char queries producing noise by requiring at least 2 chars here.
+ if (collapsedQuery.length >= 2 && isSubsequence(collapsedQuery, lower)) {
+ return SCORE_SUBSEQUENCE;
+ }
+
+ return null;
+}
+
+export type ChannelSearchable = {
+ name: string;
+ description: string;
+};
+
+/**
+ * Score a channel against a query, considering both name and description.
+ * Description matches are always ranked below any name match. Returns `null`
+ * when neither field matches.
+ */
+export function scoreChannelMatch(
+ channel: ChannelSearchable,
+ lowerQuery: string,
+): number | null {
+ if (lowerQuery.length === 0) return SCORE_EXACT;
+
+ const nameScore = scoreChannelName(channel.name, lowerQuery);
+ if (nameScore !== null) return nameScore;
+
+ // Description only does plain substring — it's supplementary context, so we
+ // don't want fuzzy description hits outranking or crowding out name matches.
+ if (channel.description.toLowerCase().includes(lowerQuery)) {
+ return SCORE_DESCRIPTION;
+ }
+
+ return null;
+}
diff --git a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx
index 5eb2cd431..8f1d162af 100644
--- a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx
+++ b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx
@@ -1,7 +1,15 @@
import * as React from "react";
-import { Compass, Search, X, type LucideIcon } from "lucide-react";
+import {
+ ArrowLeft,
+ Compass,
+ Plus,
+ Search,
+ X,
+ type LucideIcon,
+} from "lucide-react";
import type { Channel } from "@/shared/api/types";
+import { scoreChannelMatch } from "@/features/channels/lib/channelSearchScore";
import { ListSortDescending } from "@/shared/ui/icons";
import {
Dialog,
@@ -26,6 +34,16 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
+import {
+ type CreateChannelInput,
+ useCreateChannelForm,
+} from "@/features/sidebar/lib/useCreateChannelForm";
+import {
+ CREATE_CHANNEL_FORM_ID,
+ CreateChannelFormFields,
+ CreateChannelFormFooter,
+} from "@/features/sidebar/ui/CreateChannelFormFields";
+
type BrowserTab = "all" | "joined" | "archived";
type ChannelSort = "alphabetical" | "members";
@@ -63,6 +81,13 @@ type ChannelBrowserDialogProps = {
onOpenChange: (open: boolean) => void;
onJoinChannel: (channelId: string) => Promise;
onSelectChannel: (channelId: string) => void;
+ /**
+ * Create a new channel/forum from within the browser. When provided, the
+ * dialog surfaces a "Create …" affordance (Are.na style) so search and
+ * create live behind a single entry point.
+ */
+ onCreateChannel?: (input: CreateChannelInput) => Promise;
+ isCreatingChannel?: boolean;
};
export function ChannelBrowserDialog({
@@ -72,6 +97,8 @@ export function ChannelBrowserDialog({
onOpenChange,
onJoinChannel,
onSelectChannel,
+ onCreateChannel,
+ isCreatingChannel = false,
}: ChannelBrowserDialogProps) {
const [query, setQuery] = React.useState("");
const [activeTab, setActiveTab] = React.useState("all");
@@ -80,6 +107,8 @@ export function ChannelBrowserDialog({
const [joiningChannelId, setJoiningChannelId] = React.useState(
null,
);
+ const [mode, setMode] = React.useState<"browse" | "create">("browse");
+ const [createInitialName, setCreateInitialName] = React.useState("");
const inputRef = React.useRef(null);
const tabListRef = React.useRef(null);
const tabTriggerRefs = React.useRef<
@@ -94,14 +123,47 @@ export function ChannelBrowserDialog({
width: 0,
});
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
+ const trimmedQuery = query.trim();
+ // Immediate (non-deferred) lowercased query. The create row's visibility
+ // (via hasExactMatch) and its label both read from the live query so they
+ // can never disagree for a frame while the fuzzy filter catches up.
+ const normalizedQuery = trimmedQuery.toLowerCase();
const isForumMode = channelTypeFilter === "forum";
- const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels";
- const searchPlaceholder = isForumMode
- ? "Search forums by name or description"
- : "Search channels by name or description";
+ const canCreate = Boolean(onCreateChannel);
+ const createKind = isForumMode ? "forum" : "stream";
+ const browseTitle = isForumMode ? "Add a forum" : "Add a channel";
+ const searchPlaceholder = canCreate
+ ? isForumMode
+ ? "Search or create a forum"
+ : "Search or create a channel"
+ : isForumMode
+ ? "Search forums by name or description"
+ : "Search channels by name or description";
const entityLabel = isForumMode ? "forum" : "channel";
+ const noopCreate = React.useCallback(async () => {}, []);
+ const createForm = useCreateChannelForm({
+ channelKind: createKind,
+ active: open && mode === "create",
+ initialName: createInitialName,
+ isCreating: isCreatingChannel,
+ onCreate: onCreateChannel ?? noopCreate,
+ onCreated: () => onOpenChange(false),
+ });
+
+ // Fuzzy match score per channel id for the current query, so both filtering
+ // and relevance-ordering share one source of truth. Empty when no query.
+ const matchScoreById = React.useMemo(() => {
+ const scores = new Map();
+ if (deferredQuery.length === 0) return scores;
+ for (const channel of channels) {
+ const score = scoreChannelMatch(channel, deferredQuery);
+ if (score !== null) scores.set(channel.id, score);
+ }
+ return scores;
+ }, [channels, deferredQuery]);
+
const matchingChannels = React.useMemo(() => {
const filtered = channels.filter(
(channel) =>
@@ -116,12 +178,8 @@ export function ChannelBrowserDialog({
return filtered;
}
- return filtered.filter(
- (channel) =>
- channel.name.toLowerCase().includes(deferredQuery) ||
- channel.description.toLowerCase().includes(deferredQuery),
- );
- }, [channels, channelTypeFilter, deferredQuery]);
+ return filtered.filter((channel) => matchScoreById.has(channel.id));
+ }, [channels, channelTypeFilter, deferredQuery, matchScoreById]);
const currentChannels = React.useMemo(
() => matchingChannels.filter((channel) => channel.archivedAt === null),
@@ -145,23 +203,67 @@ export function ChannelBrowserDialog({
? joinedChannels
: matchingChannels;
+ const isSearching = deferredQuery.length > 0;
+
const orderedVisibleChannels = React.useMemo(() => {
return [...visibleChannels].sort((a, b) => {
+ // While searching, best match wins so the channel you meant floats to
+ // the top; ties fall back to the user's chosen sort below.
+ if (isSearching) {
+ const scoreA = matchScoreById.get(a.id) ?? Number.POSITIVE_INFINITY;
+ const scoreB = matchScoreById.get(b.id) ?? Number.POSITIVE_INFINITY;
+ if (scoreA !== scoreB) return scoreA - scoreB;
+ }
+
if (sort === "members" && b.memberCount !== a.memberCount) {
return b.memberCount - a.memberCount;
}
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
- }, [sort, visibleChannels]);
+ }, [isSearching, matchScoreById, sort, visibleChannels]);
const allTabLabel = isForumMode ? "All forums" : "All channels";
+ // Whether an exact name match already exists — if so we don't offer to
+ // create a duplicate, mirroring how you'd never make two "#general"s.
+ const hasExactMatch = React.useMemo(
+ () =>
+ channels.some(
+ (channel) =>
+ channel.channelType !== "dm" &&
+ channel.name.toLowerCase() === normalizedQuery &&
+ (channelTypeFilter
+ ? channel.channelType === channelTypeFilter
+ : true),
+ ),
+ [channels, channelTypeFilter, normalizedQuery],
+ );
+
+ // The pinned create row (Are.na style) appears for any non-empty query that
+ // isn't already an exact channel name — covering both partial-match and
+ // no-match cases, so a dedicated empty-state button would be redundant.
+ // The create row is present from the moment the dialog opens (so it's clear
+ // you can browse *or* create), then specializes to "Create «query»" as you
+ // type. It only hides when the query is an exact match for an existing name
+ // — creating a duplicate "#general" makes no sense.
+ const showCreateRow = canCreate && !hasExactMatch;
+
+ // The create row participates in keyboard navigation as a virtual item so
+ // arrow keys reach it and Enter activates it — not just Tab. It's rendered
+ // pinned at the top, so it takes nav index 0 and channels shift down by one,
+ // keeping keyboard order identical to visual order.
+ const channelNavOffset = showCreateRow ? 1 : 0;
+ const createRowIndex = showCreateRow ? 0 : null;
+ const navItemCount = orderedVisibleChannels.length + channelNavOffset;
+ const isCreateRowSelected =
+ createRowIndex !== null && selectedIndex === createRowIndex;
+
const updateTabIndicator = React.useCallback(() => {
const list = tabListRef.current;
const trigger = tabTriggerRefs.current[activeTab];
- if (!open || !list || !trigger) {
+ if (!open || mode !== "browse" || !list || !trigger) {
return;
}
@@ -176,12 +278,12 @@ export function ChannelBrowserDialog({
? current
: nextIndicator,
);
- }, [activeTab, open]);
+ }, [activeTab, mode, open]);
React.useLayoutEffect(() => {
updateTabIndicator();
- if (!open) {
+ if (!open || mode !== "browse") {
return;
}
@@ -212,7 +314,7 @@ export function ChannelBrowserDialog({
window.cancelAnimationFrame(frameId);
observer.disconnect();
};
- }, [open, updateTabIndicator]);
+ }, [mode, open, updateTabIndicator]);
React.useEffect(() => {
if (!open) {
@@ -221,19 +323,21 @@ export function ChannelBrowserDialog({
setSort("alphabetical");
setSelectedIndex(null);
setJoiningChannelId(null);
+ setMode("browse");
+ setCreateInitialName("");
return;
}
}, [open]);
React.useEffect(() => {
setSelectedIndex((current) => {
- if (current === null || orderedVisibleChannels.length === 0) {
+ if (current === null || navItemCount === 0) {
return null;
}
- return Math.min(current, orderedVisibleChannels.length - 1);
+ return Math.min(current, navItemCount - 1);
});
- }, [orderedVisibleChannels]);
+ }, [navItemCount]);
async function handleJoin(channelId: string) {
setJoiningChannelId(channelId);
@@ -252,8 +356,25 @@ export function ChannelBrowserDialog({
onSelectChannel(channel.id);
}
+ function enterCreateMode(prefillName: string) {
+ setCreateInitialName(prefillName);
+ setMode("create");
+ }
+
+ function exitCreateMode() {
+ setMode("browse");
+ // Return focus to the search field so keyboard users stay oriented.
+ window.requestAnimationFrame(() => {
+ inputRef.current?.focus();
+ });
+ }
+
+ // Map the flat nav index back to a channel, accounting for the create row
+ // occupying index 0 when present.
const selectedItem =
- selectedIndex !== null ? orderedVisibleChannels[selectedIndex] : undefined;
+ selectedIndex !== null && !isCreateRowSelected
+ ? orderedVisibleChannels[selectedIndex - channelNavOffset]
+ : undefined;
const emptyTitle =
deferredQuery.length > 0
? `No ${entityLabel}s match your search`
@@ -264,7 +385,9 @@ export function ChannelBrowserDialog({
: `No ${entityLabel}s to browse`;
const emptyDescription =
deferredQuery.length > 0
- ? "Try a different name or keyword."
+ ? canCreate
+ ? `No ${entityLabel} by that name yet — create it to get started.`
+ : "Try a different name or keyword."
: activeTab === "archived"
? `Archived ${entityLabel}s you have joined will appear here.`
: activeTab === "joined"
@@ -281,206 +404,343 @@ export function ChannelBrowserDialog({
}
onOpenAutoFocus={(event) => {
event.preventDefault();
- inputRef.current?.focus({ preventScroll: true });
+ if (mode === "browse") {
+ inputRef.current?.focus({ preventScroll: true });
+ }
}}
showCloseButton={false}
>
-
-
- {browseTitle}
-
-
- Close
-
-
-
-
-
-
- onOpenChange(false)}
+ />
+ ) : (
+ <>
+
+
+ {browseTitle}
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+ Sort by
+ {
+ setSort(value as ChannelSort);
+ setSelectedIndex(null);
+ }}
+ value={sort}
+ >
+ {CHANNEL_SORT_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ {
- setSort(value as ChannelSort);
+ setActiveTab(value as BrowserTab);
setSelectedIndex(null);
}}
- value={sort}
+ value={activeTab}
>
- {CHANNEL_SORT_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
-
-
-
-
-
- {
- setActiveTab(value as BrowserTab);
- setSelectedIndex(null);
- }}
- value={activeTab}
- >
-
-
-
-
-
- {orderedVisibleChannels.length === 0 ? (
- 0 ? Search : Compass}
- title={emptyTitle}
- />
- ) : (
-
- {orderedVisibleChannels.map((channel, index) => (
- {
- void handleJoin(channel.id);
- }
- : undefined
- }
- onSelect={() => handleSelect(channel)}
+
+
+
+
+
+ {showCreateRow ? (
+
+ enterCreateMode(trimmedQuery)}
+ query={trimmedQuery}
+ />
+
+ ) : null}
+
+ {orderedVisibleChannels.length === 0 ? (
+ 0 ? Search : Compass}
+ title={emptyTitle}
+ />
+ ) : (
+
+ {orderedVisibleChannels.map((channel, index) => (
+ {
+ void handleJoin(channel.id);
+ }
+ : undefined
+ }
+ onSelect={() => handleSelect(channel)}
+ />
+ ))}
+
+ )}
- )}
+
-
-
+ >
+ )}
);
}
+function CreateChannelRow({
+ entityLabel,
+ isSelected,
+ onClick,
+ query,
+}: {
+ entityLabel: string;
+ isSelected: boolean;
+ onClick: () => void;
+ query: string;
+}) {
+ const hasQuery = query.length > 0;
+ return (
+
+
+
+
+ {hasQuery ? (
+
+
+ Create {entityLabel}{" "}
+
+ “{query}”
+
+ ) : (
+
+ Create a new {entityLabel}
+
+ )}
+
+ );
+}
+
+function ChannelCreateView({
+ entityLabel,
+ form,
+ onBack,
+ onClose,
+}: {
+ entityLabel: string;
+ form: ReturnType;
+ onBack: () => void;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {`New ${entityLabel}`}
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
function ChannelCard({
channel,
isJoining,
diff --git a/desktop/src/features/sidebar/lib/useCreateChannelForm.ts b/desktop/src/features/sidebar/lib/useCreateChannelForm.ts
new file mode 100644
index 000000000..4b3021950
--- /dev/null
+++ b/desktop/src/features/sidebar/lib/useCreateChannelForm.ts
@@ -0,0 +1,214 @@
+import { ClockFading, Hash, type LucideIcon } from "lucide-react";
+import * as React from "react";
+
+import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
+import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel";
+import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
+
+export type CreateChannelKind = "stream" | "forum";
+
+export type CreateChannelInput = {
+ name: string;
+ description?: string;
+ visibility: ChannelVisibility;
+ ttlSeconds?: number;
+ templateId?: string;
+};
+
+type UseCreateChannelFormOptions = {
+ channelKind: CreateChannelKind;
+ /**
+ * When this flips to `true` the form resets its fields (and applies
+ * `initialName`). Pass the dialog/mode's open state.
+ */
+ active: boolean;
+ initialName?: string;
+ isCreating: boolean;
+ onCreate: (input: CreateChannelInput) => Promise;
+ onCreated?: () => void;
+ autoFocusName?: boolean;
+};
+
+export type CreateChannelFormState = {
+ channelKind: CreateChannelKind;
+ kindLabel: string;
+ name: string;
+ setName: (value: string) => void;
+ description: string;
+ setDescription: (value: string) => void;
+ visibility: ChannelVisibility;
+ setVisibility: (value: ChannelVisibility) => void;
+ ephemeral: boolean;
+ setEphemeral: (value: boolean) => void;
+ durationLabel: string;
+ DurationIcon: LucideIcon;
+ typePopoverOpen: boolean;
+ setTypePopoverOpen: (open: boolean) => void;
+ errorMessage: string | null;
+ selectedTemplateId: string | null;
+ handleTemplateChange: (templateId: string) => void;
+ templates: ChannelTemplate[];
+ nameInputRef: React.RefObject;
+ isCreating: boolean;
+ canSubmit: boolean;
+ handleSubmit: (event: React.FormEvent) => void;
+};
+
+/**
+ * Shared state + submit logic for the create-channel form. Powers both the
+ * standalone `CreateChannelDialog` and the create mode of the unified
+ * "Add channel" browser dialog, so the two stay behaviorally identical.
+ */
+export function useCreateChannelForm({
+ channelKind,
+ active,
+ initialName,
+ isCreating,
+ onCreate,
+ onCreated,
+ autoFocusName = true,
+}: UseCreateChannelFormOptions): CreateChannelFormState {
+ const [name, setName] = React.useState(initialName ?? "");
+ const [description, setDescription] = React.useState("");
+ const [visibility, setVisibility] = React.useState("open");
+ const [ephemeral, setEphemeral] = React.useState(false);
+ const [errorMessage, setErrorMessage] = React.useState(null);
+ const [selectedTemplateId, setSelectedTemplateId] = React.useState<
+ string | null
+ >(null);
+ const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
+ const nameInputRef = React.useRef(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(() => {
+ if (!active) return;
+
+ setName(initialName ?? "");
+ setDescription("");
+ setVisibility("open");
+ setEphemeral(false);
+ setErrorMessage(null);
+ setSelectedTemplateId(null);
+ setTypePopoverOpen(false);
+
+ if (!autoFocusName) return;
+
+ // Small delay to let the dialog animation start before focusing.
+ const timerId = globalThis.setTimeout(() => {
+ const activeElement = document.activeElement;
+ if (
+ activeElement instanceof HTMLElement &&
+ activeElement.closest("#create-channel-form")
+ ) {
+ return;
+ }
+ const input = nameInputRef.current;
+ if (!input) return;
+ input.focus();
+ // Place the caret at the end of any prefilled name.
+ const end = input.value.length;
+ input.setSelectionRange(end, end);
+ }, 50);
+ return () => globalThis.clearTimeout(timerId);
+ }, [active, autoFocusName, initialName]);
+
+ const handleTemplateChange = React.useCallback(
+ (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);
+ },
+ [templates],
+ );
+
+ const handleSubmit = React.useCallback(
+ (event: React.FormEvent) => {
+ event.preventDefault();
+
+ const trimmedName = name.trim();
+ if (!trimmedName) return;
+
+ setErrorMessage(null);
+
+ void (async () => {
+ try {
+ await onCreate({
+ name: trimmedName,
+ description: description.trim() || undefined,
+ visibility,
+ ttlSeconds: ephemeral ? DEFAULT_EPHEMERAL_TTL_SECONDS : undefined,
+ templateId: selectedTemplateId ?? undefined,
+ });
+ onCreated?.();
+ } catch (error) {
+ setErrorMessage(
+ error instanceof Error
+ ? error.message
+ : `Failed to create ${kindLabel}.`,
+ );
+ }
+ })();
+ },
+ [
+ description,
+ ephemeral,
+ kindLabel,
+ name,
+ onCreate,
+ onCreated,
+ selectedTemplateId,
+ visibility,
+ ],
+ );
+
+ return {
+ channelKind,
+ kindLabel,
+ name,
+ setName: (value: string) => {
+ setName(value);
+ setErrorMessage(null);
+ },
+ description,
+ setDescription: (value: string) => {
+ setDescription(value);
+ setErrorMessage(null);
+ },
+ visibility,
+ setVisibility,
+ ephemeral,
+ setEphemeral,
+ durationLabel,
+ DurationIcon,
+ typePopoverOpen,
+ setTypePopoverOpen,
+ errorMessage,
+ selectedTemplateId,
+ handleTemplateChange,
+ templates,
+ nameInputRef,
+ isCreating,
+ canSubmit: name.trim().length > 0 && !isCreating,
+ handleSubmit,
+ };
+}
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index bc86d97ab..7010986fe 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -678,8 +678,6 @@ export function AppSidebar({
/>
))}
0}
isCollapsed={collapsedGroups.channels}
@@ -692,8 +690,8 @@ export function AppSidebar({
}
actionsTestId="section-actions-channels"
listTestId="stream-list"
- onBrowseClick={onBrowseChannels}
- onCreateClick={() => openCreateDialog("stream")}
+ quickCreateLabel="Add channel"
+ onQuickCreateClick={onBrowseChannels}
showQuickCreate
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
diff --git a/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx b/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx
index bb98754a1..b2554c52f 100644
--- a/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx
+++ b/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx
@@ -1,24 +1,16 @@
-import { ChevronDown, ClockFading, Hash, type LucideIcon } from "lucide-react";
-import * as React from "react";
-
-import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
-import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel";
-import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
-import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
+import type { ChannelVisibility } from "@/shared/api/types";
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";
-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";
+import {
+ type CreateChannelInput,
+ useCreateChannelForm,
+} from "@/features/sidebar/lib/useCreateChannelForm";
+import {
+ CREATE_CHANNEL_FORM_ID,
+ CreateChannelFormFields,
+ CreateChannelFormFooter,
+} from "@/features/sidebar/ui/CreateChannelFormFields";
type ChannelKind = "stream" | "forum";
@@ -43,100 +35,16 @@ export function CreateChannelDialog({
onCreate,
}: CreateChannelDialogProps) {
const open = channelKind !== null;
- const [name, setName] = React.useState("");
- const [description, setDescription] = React.useState("");
- const [visibility, setVisibility] = React.useState("open");
- const [ephemeral, setEphemeral] = React.useState(false);
- const [errorMessage, setErrorMessage] = React.useState(null);
- const [selectedTemplateId, setSelectedTemplateId] = React.useState<
- string | null
- >(null);
- const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
- const nameInputRef = React.useRef(null);
- const templatesQuery = useChannelTemplatesQuery();
- const templates = templatesQuery.data ?? [];
+ const form = useCreateChannelForm({
+ channelKind: channelKind ?? "stream",
+ active: open,
+ isCreating,
+ onCreate: onCreate as (input: CreateChannelInput) => Promise,
+ onCreated: () => onOpenChange(false),
+ });
const kindLabel = channelKind === "forum" ? "forum" : "channel";
- const durationLabel = ephemeral ? "Temporary" : "Ongoing";
- const DurationIcon = ephemeral ? ClockFading : Hash;
-
- 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(() => {
- const activeElement = document.activeElement;
- if (
- activeElement instanceof HTMLElement &&
- activeElement.closest("#create-channel-form")
- ) {
- return;
- }
- 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);
-
- 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) {
- event.preventDefault();
-
- const trimmedName = name.trim();
- if (!trimmedName) return;
-
- setErrorMessage(null);
-
- try {
- await onCreate({
- name: trimmedName,
- description: description.trim() || undefined,
- visibility,
- ttlSeconds: ephemeral ? DEFAULT_EPHEMERAL_TTL_SECONDS : undefined,
- templateId: selectedTemplateId ?? undefined,
- });
-
- onOpenChange(false);
- } catch (error) {
- setErrorMessage(
- error instanceof Error
- ? error.message
- : `Failed to create ${kindLabel}.`,
- );
- }
- }
return (
- }
+ footer={ }
>
);
}
-
-function ChannelDurationOption({
- ariaLabel,
- checked,
- description,
- icon: Icon,
- label,
- onSelect,
-}: {
- ariaLabel: string;
- checked: boolean;
- description: string;
- icon: LucideIcon;
- label: string;
- onSelect: () => void;
-}) {
- return (
-
- );
-}
diff --git a/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx b/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx
new file mode 100644
index 000000000..21d13e4bd
--- /dev/null
+++ b/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx
@@ -0,0 +1,301 @@
+import { ChevronDown, ClockFading, Hash } from "lucide-react";
+
+import type { ChannelTemplate } 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";
+
+import type { CreateChannelFormState } from "@/features/sidebar/lib/useCreateChannelForm";
+
+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 const CREATE_CHANNEL_FORM_ID = "create-channel-form";
+
+/**
+ * The body of the create-channel form (name, description, private toggle,
+ * optional template). Rendered inside both the standalone dialog and the
+ * "Add channel" browser's create mode. Wrap in a `