Make welcome channel inserts searchable

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-28 08:35:25 +01:00
parent e8ae0f486a
commit b65fdc9245
3 changed files with 263 additions and 40 deletions

View File

@@ -0,0 +1,166 @@
import { Hash, Search, X } from "lucide-react";
import * as React from "react";
import { buildChannelLink } from "@/features/messages/lib/channelLink";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { ACTION_TRAY_SURFACE_CLASS } from "@/shared/ui/actionTray";
import { Button } from "@/shared/ui/button";
import { POPOVER_CUSTOM_ENTER_MOTION_CLASS } from "@/shared/ui/popoverSurface";
type WelcomeChannelChipPickerProps = {
channels: Channel[];
insert: { id: string; url: string };
onClose: () => void;
onRemove: () => void;
onSelect: (channel: Channel) => void;
position: { left: number; top: number };
};
export function WelcomeChannelChipPicker({
channels,
insert,
onClose,
onRemove,
onSelect,
position,
}: WelcomeChannelChipPickerProps) {
const [query, setQuery] = React.useState("");
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
const matchingChannels = React.useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return channels
.filter(
(channel) =>
channel.channelType !== "dm" && channel.archivedAt === null,
)
.filter(
(channel) =>
!normalizedQuery ||
channel.name.toLowerCase().includes(normalizedQuery) ||
channel.id.toLowerCase().includes(normalizedQuery),
)
.sort((a, b) => a.name.localeCompare(b.name));
}, [channels, query]);
const highlightedChannel = matchingChannels[highlightedIndex];
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (matchingChannels.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((index) =>
Math.min(index + 1, matchingChannels.length - 1),
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((index) => Math.max(index - 1, 0));
} else if (event.key === "Enter" && highlightedChannel) {
event.preventDefault();
onSelect(highlightedChannel);
}
}
return (
<div
aria-label="Edit channel"
className={cn(
"absolute z-30 w-80 space-y-3 rounded-xl p-4",
ACTION_TRAY_SURFACE_CLASS,
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
)}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
onClose();
}}
role="dialog"
style={position}
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold leading-none">Channel</p>
<Button
aria-label="Close chip editor"
className="h-8 w-8"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="overflow-hidden rounded-lg border border-border/60 bg-muted/50">
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
aria-controls={`welcome-channel-results-${insert.id}`}
aria-activedescendant={
highlightedChannel
? `welcome-channel-result-${highlightedChannel.id}`
: undefined
}
aria-expanded="true"
aria-label="Search channels"
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
onChange={(event) => {
setQuery(event.target.value);
setHighlightedIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder="Search channels…"
role="combobox"
spellCheck={false}
value={query}
/>
</div>
<div
aria-label="Channel results"
className="max-h-52 overflow-y-auto p-1"
id={`welcome-channel-results-${insert.id}`}
role="listbox"
>
{matchingChannels.length > 0 ? (
matchingChannels.map((channel, index) => (
<button
aria-selected={insert.url === buildChannelLink(channel.id)}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none",
index === highlightedIndex &&
"bg-accent text-accent-foreground",
)}
data-testid={`welcome-channel-result-${channel.id}`}
id={`welcome-channel-result-${channel.id}`}
key={channel.id}
onClick={() => onSelect(channel)}
onMouseMove={() => setHighlightedIndex(index)}
role="option"
type="button"
>
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{channel.name}</span>
</button>
))
) : (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
No channels found.
</p>
)}
</div>
</div>
<Button
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={onRemove}
size="sm"
type="button"
variant="ghost"
>
Remove chip
</Button>
</div>
);
}

View File

@@ -15,7 +15,10 @@ import {
import * as React from "react";
import { toast } from "sonner";
import { useChannelsQuery } from "@/features/channels/hooks";
import { buildChannelLink } from "@/features/messages/lib/channelLink";
import { uploadMediaFile } from "@/shared/api/tauriMedia";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useMediaProxyPort } from "@/shared/lib/useMediaProxyPort";
@@ -35,6 +38,7 @@ import {
MENTION_CHIP_HOVER_CLASSES,
} from "@/shared/ui/mentionChip";
import { POPOVER_CUSTOM_ENTER_MOTION_CLASS } from "@/shared/ui/popoverSurface";
import { WelcomeChannelChipPicker } from "./WelcomeChannelChipPicker";
type WelcomeInsertType = "link" | "image" | "channel";
type WelcomeInsert = {
@@ -273,18 +277,40 @@ function readEditorContent(root: HTMLElement): string {
}
function InlineChipEditor({
channels,
insert,
onChange,
onClose,
onRemove,
position,
}: {
channels: Channel[];
insert: WelcomeInsert;
onChange: (insert: WelcomeInsert) => void;
onClose: () => void;
onRemove: () => void;
position: CaretPosition;
}) {
if (insert.type === "channel") {
return (
<WelcomeChannelChipPicker
channels={channels}
insert={insert}
onClose={onClose}
onRemove={onRemove}
onSelect={(channel) => {
onChange({
...insert,
title: channel.name,
url: buildChannelLink(channel.id),
});
onClose();
}}
position={position}
/>
);
}
return (
<div
aria-label={`Edit ${insert.type}`}
@@ -325,41 +351,23 @@ function InlineChipEditor({
</Button>
</div>
<Input
aria-label={
insert.type === "link"
? "Link title"
: insert.type === "image"
? "Image alt text"
: "Channel name"
}
aria-label={insert.type === "link" ? "Link title" : "Image alt text"}
className="bg-muted/50"
onChange={(event) => onChange({ ...insert, title: event.target.value })}
placeholder={
insert.type === "link"
? "Community guide"
: insert.type === "image"
? "Team photo"
: "introductions"
}
placeholder={insert.type === "link" ? "Community guide" : "Team photo"}
value={insert.title}
/>
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-2.5 z-10 flex items-center text-muted-foreground">
{insert.type === "link" ? (
<Link2 className="h-4 w-4" />
) : insert.type === "image" ? (
<ImageIcon className="h-4 w-4" />
) : (
<Hash className="h-4 w-4" />
<ImageIcon className="h-4 w-4" />
)}
</span>
<Input
aria-label={
insert.type === "link"
? "Link destination"
: insert.type === "image"
? "Image source"
: "Channel destination"
insert.type === "link" ? "Link destination" : "Image source"
}
autoCapitalize="off"
className="bg-muted/50 pl-8"
@@ -367,9 +375,7 @@ function InlineChipEditor({
placeholder={
insert.type === "link"
? "https://example.com"
: insert.type === "image"
? "https://example.com/image.png"
: "Channel ID or link"
: "https://example.com/image.png"
}
spellCheck={false}
value={insert.url}
@@ -489,6 +495,7 @@ export function WelcomeComposer({
onChange: React.Dispatch<React.SetStateAction<WelcomeMessage>>;
onUploadCountChange: React.Dispatch<React.SetStateAction<number>>;
}) {
const channels = useChannelsQuery().data ?? [];
const wrapperRef = React.useRef<HTMLDivElement>(null);
const editorRef = React.useRef<HTMLDivElement>(null);
const savedRangeRef = React.useRef<Range | null>(null);
@@ -602,7 +609,7 @@ export function WelcomeComposer({
? "New link"
: type === "image"
? "New image"
: "introductions"),
: "Choose a channel"),
url: "",
};
insertedId = insert.id;
@@ -624,9 +631,27 @@ export function WelcomeComposer({
onChange({ text: readEditorContent(editor), inserts });
setSelectionToolbar(null);
editor.focus();
if (type === "channel" && insertedId) {
window.requestAnimationFrame(() => openChipEditor(node, insertedId));
}
return insertedId;
}
function openChipEditor(chip: HTMLElement, id: string) {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const chipRect = chip.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
setChipEditor({
id,
left: Math.min(
chipRect.left - wrapperRect.left,
Math.max(0, wrapperRect.width - 320),
),
top: chipRect.bottom - wrapperRect.top + 6,
});
}
function imageDropRangeAtPoint(clientX: number, clientY: number) {
const editor = editorRef.current;
const wrapper = wrapperRef.current;
@@ -742,22 +767,11 @@ export function WelcomeComposer({
const chip = (event.target as HTMLElement).closest<HTMLElement>(
"[data-insert-id]",
);
const wrapper = wrapperRef.current;
if (!chip?.dataset.insertId || !wrapper) {
if (!chip?.dataset.insertId) {
setChipEditor(null);
return;
}
const chipRect = chip.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
setChipEditor({
id: chip.dataset.insertId,
left: Math.min(
chipRect.left - wrapperRect.left,
Math.max(0, wrapperRect.width - 320),
),
top: chipRect.bottom - wrapperRect.top + 6,
});
openChipEditor(chip, chip.dataset.insertId);
}
const selectedInsert = chipEditor
@@ -857,7 +871,9 @@ export function WelcomeComposer({
</ContextMenuItem>
<ContextMenuItem
className="hover:bg-muted/50 hover:text-foreground"
onSelect={() => insertAtCaret("channel")}
onSelect={() => {
window.setTimeout(() => insertAtCaret("channel"));
}}
>
<Hash className="h-4 w-4" />
Channel
@@ -898,6 +914,7 @@ export function WelcomeComposer({
{selectedInsert && chipEditor ? (
<InlineChipEditor
channels={channels}
insert={selectedInsert}
onChange={(nextInsert) =>
onChange({

View File

@@ -390,6 +390,46 @@ test("builds a personalized welcome sequence", async ({ page }) => {
await expect(builder.getByRole("button", { name: "Save" })).toBeDisabled();
});
test("channel inserts search the community channel list", async ({ page }) => {
const welcomeRow = page.getByTestId("welcome-channel-row");
await welcomeRow.scrollIntoViewIfNeeded();
await welcomeRow.getByRole("button", { name: "Create" }).click();
const builder = page.getByTestId("welcome-message-builder");
const editor = builder.getByTestId("welcome-inline-message");
await editor.click({ button: "right", position: { x: 100, y: 22 } });
await page.getByRole("menuitem", { name: "Channel" }).click();
const channelEditor = page.getByRole("dialog", { name: "Edit channel" });
const channelSearch = channelEditor.getByRole("combobox", {
name: "Search channels",
});
await expect(channelSearch).toBeVisible();
await expect(page.getByLabel("Channel name")).toHaveCount(0);
await expect(page.getByLabel("Channel destination")).toHaveCount(0);
await channelSearch.fill("rand");
const randomResult = channelEditor.getByRole("option", {
name: "random",
exact: true,
});
await expect(randomResult).toBeVisible();
await waitForAnimations(page);
await builder.screenshot({
path: `${OUTDIR}/07-welcome-channel-search.png`,
});
await randomResult.click();
const randomChip = editor.locator("[data-insert-id]", { hasText: "random" });
await expect(randomChip).toBeVisible();
await randomChip.click();
await expect(
page
.getByRole("dialog", { name: "Edit channel" })
.getByRole("option", { name: "random", exact: true }),
).toHaveAttribute("aria-selected", "true");
});
test("capture: share-style community invite dialog", async ({ page }) => {
await page.getByTestId("community-invite-dialog-trigger").click();