diff --git a/desktop/src/features/community-members/ui/WelcomeChannelChipPicker.tsx b/desktop/src/features/community-members/ui/WelcomeChannelChipPicker.tsx new file mode 100644 index 0000000000..cf598198fd --- /dev/null +++ b/desktop/src/features/community-members/ui/WelcomeChannelChipPicker.tsx @@ -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) { + 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 ( +
{ + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + onClose(); + }} + role="dialog" + style={position} + > +
+

Channel

+ +
+
+
+ + { + setQuery(event.target.value); + setHighlightedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Search channels…" + role="combobox" + spellCheck={false} + value={query} + /> +
+
+ {matchingChannels.length > 0 ? ( + matchingChannels.map((channel, index) => ( + + )) + ) : ( +

+ No channels found. +

+ )} +
+
+ +
+ ); +} diff --git a/desktop/src/features/community-members/ui/WelcomeChannelSettingsCard.tsx b/desktop/src/features/community-members/ui/WelcomeChannelSettingsCard.tsx index 358cea822c..915241c82c 100644 --- a/desktop/src/features/community-members/ui/WelcomeChannelSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/WelcomeChannelSettingsCard.tsx @@ -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 ( + { + onChange({ + ...insert, + title: channel.name, + url: buildChannelLink(channel.id), + }); + onClose(); + }} + position={position} + /> + ); + } + return (
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} />
{insert.type === "link" ? ( - ) : insert.type === "image" ? ( - ) : ( - + )} >; onUploadCountChange: React.Dispatch>; }) { + const channels = useChannelsQuery().data ?? []; const wrapperRef = React.useRef(null); const editorRef = React.useRef(null); const savedRangeRef = React.useRef(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( "[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({ insertAtCaret("channel")} + onSelect={() => { + window.setTimeout(() => insertAtCaret("channel")); + }} > Channel @@ -898,6 +914,7 @@ export function WelcomeComposer({ {selectedInsert && chipEditor ? ( onChange({ diff --git a/desktop/tests/e2e/invites-settings-screenshots.spec.ts b/desktop/tests/e2e/invites-settings-screenshots.spec.ts index d268c85c6c..675fa9efbd 100644 --- a/desktop/tests/e2e/invites-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/invites-settings-screenshots.spec.ts @@ -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();