unify channel add + search into one entry point (#1964)

This commit is contained in:
tulsi
2026-07-16 18:01:53 +00:00
committed by GitHub
parent 6c2d667575
commit 3dd236eb6f
14 changed files with 1598 additions and 627 deletions
+1
View File
@@ -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",
+85 -49
View File
@@ -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);
+7
View File
@@ -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<void>;
onBrowseChannelCreate?: (input: CreateChannelInput) => Promise<void>;
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({
<ChannelBrowserDialog
channels={channels}
channelTypeFilter={renderedBrowseDialogType ?? browseDialogType}
isCreatingChannel={isCreatingBrowseChannel}
onCreateChannel={onBrowseChannelCreate}
onJoinChannel={onBrowseChannelJoin}
onOpenChange={onBrowseDialogOpenChange}
onSelectChannel={onSelectChannel}
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import test from "node:test";
import { scoreChannelMatch, scoreChannelName } from "./channelSearchScore.ts";
test("scoreChannelName: empty query matches everything at top score", () => {
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,
);
});
@@ -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;
}
@@ -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<void>;
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<void>;
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<BrowserTab>("all");
@@ -80,6 +107,8 @@ export function ChannelBrowserDialog({
const [joiningChannelId, setJoiningChannelId] = React.useState<string | null>(
null,
);
const [mode, setMode] = React.useState<"browse" | "create">("browse");
const [createInitialName, setCreateInitialName] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const tabListRef = React.useRef<HTMLDivElement>(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<string, number>();
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}
>
<DialogHeader className="space-y-0 pb-5">
<div className="flex items-center justify-between gap-4">
<DialogTitle>{browseTitle}</DialogTitle>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<div className={MODAL_SEARCH_SHELL_CLASS}>
<label
className="flex min-w-0 flex-1 cursor-text items-center gap-3"
htmlFor="channel-browser-search"
>
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-hover/search:text-muted-foreground group-focus-within/search:text-foreground" />
<input
autoCapitalize="none"
autoCorrect="off"
className={MODAL_SEARCH_INPUT_CLASS}
data-testid="channel-browser-search"
id="channel-browser-search"
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(null);
}}
onKeyDown={(event) => {
if (
event.key === "ArrowDown" &&
orderedVisibleChannels.length > 0
) {
event.preventDefault();
setSelectedIndex((current) =>
current === null
? 0
: Math.min(
current + 1,
orderedVisibleChannels.length - 1,
),
);
return;
}
if (
event.key === "ArrowUp" &&
orderedVisibleChannels.length > 0
) {
event.preventDefault();
setSelectedIndex((current) =>
current === null
? orderedVisibleChannels.length - 1
: Math.max(current - 1, 0),
);
return;
}
if (
event.key === "Enter" &&
!event.nativeEvent.isComposing &&
orderedVisibleChannels.length > 0
) {
event.preventDefault();
handleSelect(selectedItem ?? orderedVisibleChannels[0]);
}
}}
placeholder={searchPlaceholder}
ref={inputRef}
spellCheck={false}
type="text"
value={query}
/>
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Sort ${entityLabel}s: ${
sort === "alphabetical" ? "Alphabetical" : "Most members"
}`}
data-testid="channel-browser-sort"
size="icon-xs"
type="button"
variant="ghost"
{mode === "create" ? (
<ChannelCreateView
entityLabel={entityLabel}
form={createForm}
onBack={exitCreateMode}
onClose={() => onOpenChange(false)}
/>
) : (
<>
<DialogHeader className="space-y-0 pb-5">
<div className="flex items-center justify-between gap-4">
<DialogTitle>{browseTitle}</DialogTitle>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<div className={MODAL_SEARCH_SHELL_CLASS}>
<label
className="flex min-w-0 flex-1 cursor-text items-center gap-3"
htmlFor="channel-browser-search"
>
<ListSortDescending />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuRadioGroup
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-hover/search:text-muted-foreground group-focus-within/search:text-foreground" />
<input
autoCapitalize="none"
autoCorrect="off"
className={MODAL_SEARCH_INPUT_CLASS}
data-testid="channel-browser-search"
id="channel-browser-search"
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(null);
}}
onKeyDown={(event) => {
// Arrow keys traverse the pinned create row (index 0)
// and the channel list beneath it, in visual order.
if (event.key === "ArrowDown" && navItemCount > 0) {
event.preventDefault();
setSelectedIndex((current) =>
current === null
? 0
: Math.min(current + 1, navItemCount - 1),
);
return;
}
if (event.key === "ArrowUp" && navItemCount > 0) {
event.preventDefault();
setSelectedIndex((current) =>
current === null
? navItemCount - 1
: Math.max(current - 1, 0),
);
return;
}
if (
event.key === "Enter" &&
!event.nativeEvent.isComposing
) {
// If the create row is highlighted — or it's the only
// actionable item (no channel matches) — Enter creates.
if (
showCreateRow &&
(isCreateRowSelected ||
orderedVisibleChannels.length === 0)
) {
event.preventDefault();
enterCreateMode(trimmedQuery);
return;
}
if (orderedVisibleChannels.length > 0) {
event.preventDefault();
handleSelect(
selectedItem ?? orderedVisibleChannels[0],
);
}
}
}}
placeholder={searchPlaceholder}
ref={inputRef}
spellCheck={false}
type="text"
value={query}
/>
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Sort ${entityLabel}s: ${
sort === "alphabetical"
? "Alphabetical"
: "Most members"
}`}
data-testid="channel-browser-sort"
size="icon-xs"
type="button"
variant="ghost"
>
<ListSortDescending />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuRadioGroup
onValueChange={(value) => {
setSort(value as ChannelSort);
setSelectedIndex(null);
}}
value={sort}
>
{CHANNEL_SORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`channel-browser-sort-${option.value}`}
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DialogHeader>
<div className="h-[min(60vh,30rem)] overflow-hidden">
<div className="flex h-full flex-col">
<Tabs
className="shrink-0"
onValueChange={(value) => {
setSort(value as ChannelSort);
setActiveTab(value as BrowserTab);
setSelectedIndex(null);
}}
value={sort}
value={activeTab}
>
{CHANNEL_SORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`channel-browser-sort-${option.value}`}
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DialogHeader>
<div className="h-[min(60vh,30rem)] overflow-hidden">
<div className="flex h-full flex-col">
<Tabs
className="shrink-0"
onValueChange={(value) => {
setActiveTab(value as BrowserTab);
setSelectedIndex(null);
}}
value={activeTab}
>
<TabsList
className="relative h-auto w-full justify-start gap-6 rounded-none border-b border-border/70 bg-transparent p-0 text-muted-foreground"
ref={tabListRef}
>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[-1px] left-0 h-0.5 w-px origin-left rounded-full bg-foreground opacity-0 transition-[transform,opacity] duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none data-[ready=true]:opacity-100"
data-ready={tabIndicator.width > 0}
data-testid="channel-browser-tab-indicator"
style={{
transform: `translate3d(${tabIndicator.left}px, 0, 0) scaleX(${tabIndicator.width})`,
}}
/>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.all = element;
}}
value="all"
>
{allTabLabel}
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.joined = element;
}}
value="joined"
>
Joined
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.archived = element;
}}
value="archived"
>
Archived
</TabsTrigger>
</TabsList>
</Tabs>
<div className="min-h-0 flex-1 overflow-y-auto pb-6 pt-4">
{orderedVisibleChannels.length === 0 ? (
<BrowseState
description={emptyDescription}
icon={deferredQuery.length > 0 ? Search : Compass}
title={emptyTitle}
/>
) : (
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55">
{orderedVisibleChannels.map((channel, index) => (
<ChannelCard
channel={channel}
isJoining={joiningChannelId === channel.id}
isSelected={index === selectedIndex}
key={channel.id}
onJoin={
!channel.isMember
? () => {
void handleJoin(channel.id);
}
: undefined
}
onSelect={() => handleSelect(channel)}
<TabsList
className="relative h-auto w-full justify-start gap-6 rounded-none border-b border-border/70 bg-transparent p-0 text-muted-foreground"
ref={tabListRef}
>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[-1px] left-0 h-0.5 w-px origin-left rounded-full bg-foreground opacity-0 transition-[transform,opacity] duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none data-[ready=true]:opacity-100"
data-ready={tabIndicator.width > 0}
data-testid="channel-browser-tab-indicator"
style={{
transform: `translate3d(${tabIndicator.left}px, 0, 0) scaleX(${tabIndicator.width})`,
}}
/>
))}
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.all = element;
}}
value="all"
>
{allTabLabel}
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.joined = element;
}}
value="joined"
>
Joined
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.archived = element;
}}
value="archived"
>
Archived
</TabsTrigger>
</TabsList>
</Tabs>
<div className="min-h-0 flex-1 overflow-y-auto pb-6 pt-4">
{showCreateRow ? (
<div className="mb-3">
<CreateChannelRow
entityLabel={entityLabel}
isSelected={isCreateRowSelected}
onClick={() => enterCreateMode(trimmedQuery)}
query={trimmedQuery}
/>
</div>
) : null}
{orderedVisibleChannels.length === 0 ? (
<BrowseState
description={emptyDescription}
icon={deferredQuery.length > 0 ? Search : Compass}
title={emptyTitle}
/>
) : (
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55">
{orderedVisibleChannels.map((channel, index) => (
<ChannelCard
channel={channel}
isJoining={joiningChannelId === channel.id}
isSelected={
index + channelNavOffset === selectedIndex
}
key={channel.id}
onJoin={
!channel.isMember
? () => {
void handleJoin(channel.id);
}
: undefined
}
onSelect={() => handleSelect(channel)}
/>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
function CreateChannelRow({
entityLabel,
isSelected,
onClick,
query,
}: {
entityLabel: string;
isSelected: boolean;
onClick: () => void;
query: string;
}) {
const hasQuery = query.length > 0;
return (
<button
className={
isSelected
? "flex w-full items-center gap-3 rounded-xl border border-border/70 bg-muted/60 px-4 py-3 text-left transition-colors duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
: "flex w-full items-center gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors duration-150 ease-out hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
}
data-testid="channel-browser-create-row"
data-selected={isSelected}
onClick={onClick}
type="button"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Plus className="h-4 w-4" />
</span>
{hasQuery ? (
<span className="min-w-0 text-sm">
<span className="font-medium text-foreground">
Create {entityLabel}{" "}
</span>
<span className="font-semibold text-foreground">{query}</span>
</span>
) : (
<span className="min-w-0 text-sm font-medium text-foreground">
Create a new {entityLabel}
</span>
)}
</button>
);
}
function ChannelCreateView({
entityLabel,
form,
onBack,
onClose,
}: {
entityLabel: string;
form: ReturnType<typeof useCreateChannelForm>;
onBack: () => void;
onClose: () => void;
}) {
return (
<div className="flex h-[min(72vh,38rem)] flex-col">
<DialogHeader className="space-y-0 pb-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<button
aria-label="Back to search"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring"
data-testid="channel-browser-create-back"
onClick={onBack}
type="button"
>
<ArrowLeft className="h-4 w-4" />
</button>
<DialogTitle className="truncate">
{`New ${entityLabel}`}
</DialogTitle>
</div>
<button
aria-label="Close"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring"
onClick={onClose}
type="button"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
<form
className="space-y-5"
id={CREATE_CHANNEL_FORM_ID}
onSubmit={form.handleSubmit}
>
<CreateChannelFormFields form={form} />
</form>
</div>
<div className="shrink-0 pb-6 pt-4">
<CreateChannelFormFooter form={form} />
</div>
</div>
);
}
function ChannelCard({
channel,
isJoining,
@@ -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<void>;
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<HTMLInputElement | null>;
isCreating: boolean;
canSubmit: boolean;
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => 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<ChannelVisibility>("open");
const [ephemeral, setEphemeral] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
>(null);
const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const templatesQuery = useChannelTemplatesQuery();
const templates = templatesQuery.data ?? [];
const kindLabel = channelKind === "forum" ? "forum" : "channel";
const durationLabel = ephemeral ? "Temporary" : "Ongoing";
const DurationIcon = ephemeral ? ClockFading : Hash;
React.useEffect(() => {
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<HTMLFormElement>) => {
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,
};
}
@@ -678,8 +678,6 @@ export function AppSidebar({
/>
))}
<ChannelGroupSection
browseLabel="Browse channels"
createLabel="New channel"
draggable
hasUnread={unreadChannelIds.size > 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}
@@ -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<ChannelVisibility>("open");
const [ephemeral, setEphemeral] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
>(null);
const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const templatesQuery = useChannelTemplatesQuery();
const templates = templatesQuery.data ?? [];
const form = useCreateChannelForm({
channelKind: channelKind ?? "stream",
active: open,
isCreating,
onCreate: onCreate as (input: CreateChannelInput) => Promise<void>,
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<HTMLFormElement>) {
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 (
<Dialog
@@ -158,267 +66,16 @@ export function CreateChannelDialog({
? "Forums organize threaded discussions around a topic."
: "Channels are real-time streams for team conversation."
}
footer={
<div className="flex w-full items-center justify-between gap-3">
<Popover onOpenChange={setTypePopoverOpen} open={typePopoverOpen}>
<PopoverTrigger asChild>
<Button
aria-label={`Channel duration: ${durationLabel}`}
className="-ml-2.5 h-9 px-2.5 text-sm font-medium text-foreground hover:bg-muted/50"
disabled={isCreating}
type="button"
variant="ghost"
>
<DurationIcon className="h-4 w-4" />
{durationLabel}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1">
<div className="px-3 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70">
Channel type
</div>
<fieldset className="space-y-1">
<legend className="sr-only">Channel type</legend>
<ChannelDurationOption
ariaLabel="Ongoing channel"
checked={!ephemeral}
description="For projects, teams, and recurring conversations."
icon={Hash}
label="Ongoing"
onSelect={() => {
setEphemeral(false);
setTypePopoverOpen(false);
}}
/>
<ChannelDurationOption
ariaLabel="Ephemeral - auto-archives after 7 days of inactivity"
checked={ephemeral}
description="For quick discussions that archive automatically when inactive."
icon={ClockFading}
label="Temporary"
onSelect={() => {
setEphemeral(true);
setTypePopoverOpen(false);
}}
/>
</fieldset>
</PopoverContent>
</Popover>
<Button
data-testid="create-channel-submit"
disabled={isCreating || name.trim().length === 0}
form="create-channel-form"
type="submit"
>
{isCreating ? "Creating..." : `Create ${kindLabel}`}
</Button>
</div>
}
footer={<CreateChannelFormFooter form={form} />}
>
<form
className="space-y-5"
id="create-channel-form"
onSubmit={(event) => {
void handleSubmit(event);
}}
id={CREATE_CHANNEL_FORM_ID}
onSubmit={form.handleSubmit}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-name"
>
Name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-name"
disabled={isCreating}
id="create-channel-name"
onChange={(event) => {
setName(event.target.value);
setErrorMessage(null);
}}
placeholder={
channelKind === "forum"
? "design-discussions"
: "release-notes"
}
ref={nameInputRef}
spellCheck={false}
value={name}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-description"
>
Description
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div className={CREATE_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-description"
disabled={isCreating}
id="create-channel-description"
onChange={(event) => {
setDescription(event.target.value);
setErrorMessage(null);
}}
placeholder={`What this ${kindLabel} is for`}
rows={2}
value={description}
/>
</div>
</div>
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl py-1",
isCreating && "opacity-50",
)}
data-testid="create-channel-visibility"
>
<label
className="min-w-0 cursor-pointer space-y-0.5"
htmlFor="create-channel-private"
>
<span className="block text-sm font-medium text-foreground">
Private
</span>
<span
className="block text-xs leading-4 text-muted-foreground/65"
id="create-channel-private-description"
>
Only members can invite people to this {kindLabel}.
</span>
</label>
<Switch
aria-describedby="create-channel-private-description"
checked={visibility === "private"}
className="shrink-0 shadow-none [&>span]:shadow-none"
data-testid="create-channel-private-toggle"
disabled={isCreating}
id="create-channel-private"
onCheckedChange={(checked) =>
setVisibility(checked ? "private" : "open")
}
/>
</div>
{templates.length > 0 ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-template"
>
Template
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<select
className="flex min-h-11 w-full rounded-xl border border-input bg-muted/40 px-3 py-2 text-sm text-muted-foreground/55 shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus:border-muted-foreground/50 focus:text-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
data-testid="create-channel-template"
disabled={isCreating}
id="create-channel-template"
onChange={(event) => handleTemplateChange(event.target.value)}
value={selectedTemplateId ?? ""}
>
<option value="">No template</option>
{templates.map((template: ChannelTemplate) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
<CreateChannelFormFields form={form} />
</form>
</ChooserDialogContent>
</Dialog>
);
}
function ChannelDurationOption({
ariaLabel,
checked,
description,
icon: Icon,
label,
onSelect,
}: {
ariaLabel: string;
checked: boolean;
description: string;
icon: LucideIcon;
label: string;
onSelect: () => void;
}) {
return (
<label
className={cn(
"relative flex min-h-16 cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 text-left text-muted-foreground/75 transition-colors duration-150 ease-out hover:bg-muted/50 hover:text-foreground has-[:focus-visible]:outline-hidden has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
checked && "text-foreground",
)}
>
<input
aria-label={ariaLabel}
checked={checked}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
name="create-channel-duration"
onChange={onSelect}
type="radio"
/>
<span
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-muted-foreground/40",
checked && "border-foreground",
)}
aria-hidden="true"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-foreground transition-opacity duration-150",
checked ? "opacity-100" : "opacity-0",
)}
/>
</span>
<span className="grid min-w-0 flex-1 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 gap-y-1">
<Icon className="h-4 w-4 shrink-0 text-current" />
<span className="block text-sm font-medium leading-4 text-current">
{label}
</span>
<span
className={cn(
"col-span-2 block text-xs leading-4 text-muted-foreground/70",
checked && "text-muted-foreground/65",
)}
>
{description}
</span>
</span>
</label>
);
}
@@ -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 `<form>` with
* `id={CREATE_CHANNEL_FORM_ID}` and hook up `form.handleSubmit`.
*/
export function CreateChannelFormFields({
form,
}: {
form: CreateChannelFormState;
}) {
const { channelKind, kindLabel, isCreating } = form;
return (
<div className="space-y-5">
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-name"
>
Name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-name"
disabled={isCreating}
id="create-channel-name"
onChange={(event) => form.setName(event.target.value)}
placeholder={
channelKind === "forum" ? "design-discussions" : "release-notes"
}
ref={form.nameInputRef}
spellCheck={false}
value={form.name}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-description"
>
Description
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div className={CREATE_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-description"
disabled={isCreating}
id="create-channel-description"
onChange={(event) => form.setDescription(event.target.value)}
placeholder={`What this ${kindLabel} is for`}
rows={2}
value={form.description}
/>
</div>
</div>
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl py-1",
isCreating && "opacity-50",
)}
data-testid="create-channel-visibility"
>
<label
className="min-w-0 cursor-pointer space-y-0.5"
htmlFor="create-channel-private"
>
<span className="block text-sm font-medium text-foreground">
Private
</span>
<span
className="block text-xs leading-4 text-muted-foreground/65"
id="create-channel-private-description"
>
Only members can invite people to this {kindLabel}.
</span>
</label>
<Switch
aria-describedby="create-channel-private-description"
checked={form.visibility === "private"}
className="shrink-0 shadow-none [&>span]:shadow-none"
data-testid="create-channel-private-toggle"
disabled={isCreating}
id="create-channel-private"
onCheckedChange={(checked) =>
form.setVisibility(checked ? "private" : "open")
}
/>
</div>
{form.templates.length > 0 ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-template"
>
Template
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<select
className="flex min-h-11 w-full rounded-xl border border-input bg-muted/40 px-3 py-2 text-sm text-muted-foreground/55 shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus:border-muted-foreground/50 focus:text-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
data-testid="create-channel-template"
disabled={isCreating}
id="create-channel-template"
onChange={(event) => form.handleTemplateChange(event.target.value)}
value={form.selectedTemplateId ?? ""}
>
<option value="">No template</option>
{form.templates.map((template: ChannelTemplate) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
) : null}
{form.errorMessage ? (
<p className="text-sm text-destructive">{form.errorMessage}</p>
) : null}
</div>
);
}
/**
* Footer for the create-channel form: the Ongoing/Temporary duration picker on
* the left and the submit button on the right. The submit button is bound to
* the form via `form={CREATE_CHANNEL_FORM_ID}`.
*/
export function CreateChannelFormFooter({
form,
submitLabel,
}: {
form: CreateChannelFormState;
submitLabel?: string;
}) {
const { DurationIcon, durationLabel, isCreating, kindLabel } = form;
return (
<div className="flex w-full items-center justify-between gap-3">
<Popover
onOpenChange={form.setTypePopoverOpen}
open={form.typePopoverOpen}
>
<PopoverTrigger asChild>
<Button
aria-label={`Channel duration: ${durationLabel}`}
className="-ml-2.5 h-9 px-2.5 text-sm font-medium text-foreground hover:bg-muted/50"
disabled={isCreating}
type="button"
variant="ghost"
>
<DurationIcon className="h-4 w-4" />
{durationLabel}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1">
<div className="px-3 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70">
Channel type
</div>
<fieldset className="space-y-1">
<legend className="sr-only">Channel type</legend>
<ChannelDurationOption
ariaLabel="Ongoing channel"
checked={!form.ephemeral}
description="For projects, teams, and recurring conversations."
icon={Hash}
label="Ongoing"
onSelect={() => {
form.setEphemeral(false);
form.setTypePopoverOpen(false);
}}
/>
<ChannelDurationOption
ariaLabel="Ephemeral - auto-archives after 7 days of inactivity"
checked={form.ephemeral}
description="For quick discussions that archive automatically when inactive."
icon={ClockFading}
label="Temporary"
onSelect={() => {
form.setEphemeral(true);
form.setTypePopoverOpen(false);
}}
/>
</fieldset>
</PopoverContent>
</Popover>
<Button
data-testid="create-channel-submit"
disabled={!form.canSubmit}
form={CREATE_CHANNEL_FORM_ID}
type="submit"
>
{isCreating ? "Creating..." : (submitLabel ?? `Create ${kindLabel}`)}
</Button>
</div>
);
}
function ChannelDurationOption({
ariaLabel,
checked,
description,
icon: Icon,
label,
onSelect,
}: {
ariaLabel: string;
checked: boolean;
description: string;
icon: typeof Hash;
label: string;
onSelect: () => void;
}) {
return (
<label
className={cn(
"relative flex min-h-16 cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 text-left text-muted-foreground/75 transition-colors duration-150 ease-out hover:bg-muted/50 hover:text-foreground has-[:focus-visible]:outline-hidden has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
checked && "text-foreground",
)}
>
<input
aria-label={ariaLabel}
checked={checked}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
name="create-channel-duration"
onChange={onSelect}
type="radio"
/>
<span
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-muted-foreground/40",
checked && "border-foreground",
)}
aria-hidden="true"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-foreground transition-opacity duration-150",
checked ? "opacity-100" : "opacity-0",
)}
/>
</span>
<span className="grid min-w-0 flex-1 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 gap-y-1">
<Icon className="h-4 w-4 shrink-0 text-current" />
<span className="block text-sm font-medium leading-4 text-current">
{label}
</span>
<span
className={cn(
"col-span-2 block text-xs leading-4 text-muted-foreground/70",
checked && "text-muted-foreground/65",
)}
>
{description}
</span>
</span>
</label>
);
}
@@ -342,6 +342,8 @@ export function ChannelGroupSection({
listTestId,
onBrowseClick,
onCreateClick,
onQuickCreateClick,
quickCreateLabel,
showQuickCreate,
onMarkAllRead,
onMarkChannelRead,
@@ -379,6 +381,14 @@ export function ChannelGroupSection({
listTestId: string;
onBrowseClick?: () => void;
onCreateClick?: () => void;
/**
* Overrides the quick-create (`+`) button's click handler. Defaults to
* `onCreateClick`. Used to point the sidebar `+` at the unified
* "Add channel" search-and-create browser instead of the bare create form.
*/
onQuickCreateClick?: () => void;
/** Overrides the quick-create button's aria-label/tooltip. */
quickCreateLabel?: string;
showQuickCreate?: boolean;
onMarkChannelRead: (
channelId: string,
@@ -485,10 +495,10 @@ export function ChannelGroupSection({
title={title}
actions={
<>
{showQuickCreate && onCreateClick ? (
{showQuickCreate && (onQuickCreateClick ?? onCreateClick) ? (
<SectionQuickAction
label={createLabel ?? "Create channel"}
onClick={onCreateClick}
label={quickCreateLabel ?? createLabel ?? "Create channel"}
onClick={(onQuickCreateClick ?? onCreateClick) as () => void}
testId={
actionsTestId ? `${actionsTestId}-quick-create` : undefined
}
@@ -0,0 +1,46 @@
import { test } from "@playwright/test";
import { installMockBridge, openChannelBrowser } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const OUTDIR = "test-results/channel-add";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("capture: add-channel default state", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-dialog").waitFor();
await waitForAnimations(page);
await page.screenshot({ path: `${OUTDIR}/01-add-channel-default.png` });
});
test("capture: create row on partial match", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("desig");
await page.getByTestId("channel-browser-create-row").waitFor();
await waitForAnimations(page);
await page.screenshot({ path: `${OUTDIR}/02-create-row-partial.png` });
});
test("capture: create row on no match", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("release-notes");
await page.getByTestId("channel-browser-create-row").waitFor();
await waitForAnimations(page);
await page.screenshot({ path: `${OUTDIR}/03-create-row-no-match.png` });
});
test("capture: prefilled create form", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("release-notes");
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-name").waitFor();
await waitForAnimations(page);
await page.screenshot({ path: `${OUTDIR}/04-create-form.png` });
});
+191
View File
@@ -121,6 +121,197 @@ test("channel browser shows no results for unmatched search", async ({
await expect(page.getByText("No channels match your search")).toBeVisible();
});
test("channel browser fuzzy-matches a subsequence", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
// "engr" is not a substring of "engineering", but it is an in-order
// subsequence — plain includes() would miss it, fuzzy matching finds it.
await page.getByTestId("channel-browser-search").fill("engr");
await expect(page.getByTestId("browse-channel-engineering")).toBeVisible();
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
});
test("channel browser matches a scattered subsequence", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
// "sls" is neither a substring nor a prefix of "sales" — it only matches as
// an in-order subsequence (s·a·l·e·s). Proves fuzzy matching end-to-end.
await page.getByTestId("channel-browser-search").fill("sls");
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
});
test("channel browser ranks the best match first", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
// "gen" is a prefix of "general" (strong match) but only a substring of
// "agents" and a subsequence of "engineering" (weaker). The prefix match
// should float to the top regardless of the alphabetical default sort.
await page.getByTestId("channel-browser-search").fill("gen");
const firstRow = page.getByTestId(/^browse-channel-/).first();
await expect(firstRow).toHaveAttribute(
"data-testid",
"browse-channel-general",
);
});
test("sidebar add-channel button opens the browser", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.getByTestId("section-actions-channels-quick-create").click();
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
});
test("create affordance is visible on open before typing", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
// The create row is present from the get-go so it's clear you can browse OR
// create — not just after you start typing.
const createRow = page.getByTestId("channel-browser-create-row");
await expect(createRow).toBeVisible();
await expect(createRow).toContainText("Create a new channel");
});
test("typing a partial match surfaces a persistent create row", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
// "desig" matches "design" by substring but is not an exact channel name,
// so both the matching channel AND the create row are shown.
await page.getByTestId("channel-browser-search").fill("desig");
const createRow = page.getByTestId("channel-browser-create-row");
await expect(createRow).toBeVisible();
await expect(createRow).toContainText("desig");
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
});
test("exact name match hides the create row", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("general");
await expect(page.getByTestId("browse-channel-general")).toBeVisible();
await expect(page.getByTestId("channel-browser-create-row")).toHaveCount(0);
});
test("no-match search pins a create row above the empty state", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("zzz-nonexistent");
await expect(page.getByText("No channels match your search")).toBeVisible();
const createRow = page.getByTestId("channel-browser-create-row");
await expect(createRow).toBeVisible();
await expect(createRow).toContainText("zzz-nonexistent");
});
test("create row leads to the prefilled create form", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("desig");
await page.getByTestId("channel-browser-create-row").click();
// Create mode reuses the shared form; the name is prefilled from the query.
await expect(page.getByTestId("create-channel-name")).toHaveValue("desig");
// Back returns to the search list without closing the dialog.
await page.getByTestId("channel-browser-create-back").click();
await expect(page.getByTestId("channel-browser-search")).toBeVisible();
});
test("creating from the browser adds the channel to the sidebar", async ({
page,
}) => {
const channelName = `browse-created-${Date.now()}`;
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await expect(page.getByTestId("create-channel-name")).toHaveValue(
channelName,
);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toContainText(channelName);
});
test("Enter with no matches jumps to create", async ({ page }) => {
const channelName = `enter-created-${Date.now()}`;
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill(channelName);
await page.keyboard.press("Enter");
await expect(page.getByTestId("create-channel-name")).toHaveValue(
channelName,
);
});
test("arrow keys reach the pinned create row and Enter activates it", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
// "desig" keeps a channel match (#design) AND the create row visible, so the
// create row is not the only actionable item — it must be reachable by
// keyboard, not just Tab.
await page.getByTestId("channel-browser-search").fill("desig");
const createRow = page.getByTestId("channel-browser-create-row");
await expect(createRow).toBeVisible();
// The create row is pinned at the top → first ArrowDown highlights it.
await page.keyboard.press("ArrowDown");
await expect(createRow).toHaveAttribute("data-selected", "true");
// Enter on the highlighted create row enters the prefilled create form.
await page.keyboard.press("Enter");
await expect(page.getByTestId("create-channel-name")).toHaveValue("desig");
});
test("Enter selects a channel when create row is not highlighted", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
// With the create row present but NOT highlighted, Enter should still select
// the first channel match rather than jumping to create.
await page.getByTestId("channel-browser-search").fill("desig");
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
await page.keyboard.press("Enter");
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("design");
});
test("joining a channel from browser adds it to the sidebar", async ({
page,
}) => {
+20 -2
View File
@@ -716,9 +716,27 @@ async function openSectionMenu(page: Page, actionsTestId: string) {
await trigger.click();
}
// The Channels section "+" now opens the unified Add-channel browser, so the
// standalone create dialog is reached via the primary-modifier + Shift + N
// keyboard shortcut (the "New channel" menu item was removed as redundant).
export async function openCreateChannelDialog(page: Page) {
await openSectionMenu(page, "section-actions-channels");
await page.getByRole("menuitem", { name: "New channel" }).click();
await page.getByTestId("app-sidebar").waitFor({ state: "visible" });
const isMacBrowser = await page.evaluate(() =>
/mac|iphone|ipad|ipod/i.test(navigator.platform),
);
await page.evaluate((isMac) => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
ctrlKey: !isMac,
key: "N",
metaKey: isMac,
shiftKey: true,
}),
);
}, isMacBrowser);
await page.getByTestId("create-channel-dialog").waitFor();
}
export async function openNewMessagePage(page: Page) {