mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
Make welcome channel inserts searchable
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user