mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
unify channel add + search into one entry point (#1964)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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` });
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user