From f45eca83bee118c5b4aecbd7d17b8b43a15f41ce Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Thu, 28 May 2026 23:29:57 -0300 Subject: [PATCH] feat: add post-publish share dialog --- convex/packages.public.test.ts | 2 +- src/__tests__/skill-detail-page.test.tsx | 5 + src/components/SkillDetailPage.tsx | 53 ++- .../SkillPublishSuccessDialog.test.tsx | 123 +++++ src/components/SkillPublishSuccessDialog.tsx | 438 ++++++++++++++++++ src/components/ui/dialog.tsx | 2 +- src/lib/postPublishFlash.test.ts | 33 ++ src/lib/postPublishFlash.ts | 47 ++ src/routes/$owner/$slug.tsx | 60 ++- src/routes/skills/publish.tsx | 9 +- src/styles.css | 9 + 11 files changed, 774 insertions(+), 7 deletions(-) create mode 100644 src/components/SkillPublishSuccessDialog.test.tsx create mode 100644 src/components/SkillPublishSuccessDialog.tsx create mode 100644 src/lib/postPublishFlash.test.ts create mode 100644 src/lib/postPublishFlash.ts diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 5cd1da5b3c..9dc34f9eda 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -2,6 +2,7 @@ import { getAuthUserId } from "@convex-dev/auth/server"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { MAX_PUBLISH_FILE_BYTES } from "./lib/publishLimits"; import { backfillLatestPackageScanStatusInternal, backfillPackageReleaseScansInternal, @@ -43,7 +44,6 @@ import { searchForViewerInternal, searchPublic, } from "./packages"; -import { MAX_PUBLISH_FILE_BYTES } from "./lib/publishLimits"; vi.mock("@convex-dev/auth/server", () => ({ getAuthUserId: vi.fn(), diff --git a/src/__tests__/skill-detail-page.test.tsx b/src/__tests__/skill-detail-page.test.tsx index 7a457d0ac4..109ba8525c 100644 --- a/src/__tests__/skill-detail-page.test.tsx +++ b/src/__tests__/skill-detail-page.test.tsx @@ -23,6 +23,11 @@ vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: ReactNode }) => children, useNavigate: () => navigateMock, useRouter: () => ({ invalidate: routerInvalidateMock }), + useRouterState: ({ + select, + }: { + select: (state: { location: { searchStr: string } }) => string; + }) => select({ location: { searchStr: "" } }), })); vi.mock("@convex-dev/auth/react", () => ({ diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index e0d1eb1295..aa36dbdfad 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -1,5 +1,5 @@ import { useAuthActions } from "@convex-dev/auth/react"; -import { useNavigate, useRouter } from "@tanstack/react-router"; +import { useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; import type { ClawdisSkillMetadata } from "clawhub-schema"; import { useAction, useMutation, useQuery } from "convex/react"; import { ArrowLeft, TriangleAlert, Upload } from "lucide-react"; @@ -32,6 +32,7 @@ import { import { SkillHeader } from "./SkillHeader"; import { buildSkillInstallTabs } from "./SkillInstallCard"; import { SkillOwnershipPanel } from "./SkillOwnershipPanel"; +import { SkillPublishSuccessDialog } from "./SkillPublishSuccessDialog"; import { SkillRelatedSection, type RelatedSkillEntry } from "./SkillRelatedSection"; import { SkillReportDialog } from "./SkillReportDialog"; import { Alert, AlertDescription } from "./ui/alert"; @@ -44,6 +45,8 @@ type SkillDetailPageProps = { redirectToCanonical?: boolean; initialData?: SkillPageInitialData | null; mode?: "detail" | "settings"; + showPostPublishSuccess?: boolean; + onDismissPostPublish?: () => void; }; type SkillFile = Doc<"skillVersions">["files"][number]; @@ -70,6 +73,15 @@ function tabFromHash(hash: string): DetailTab { return "readme"; } +function isPostPublishSearchValue(value: unknown) { + const normalized = typeof value === "string" ? value.trim().replace(/^"|"$/g, "") : value; + return normalized === "1" || normalized === "true" || normalized === 1 || normalized === true; +} + +function hasPostPublishSearch(searchStr: string) { + return isPostPublishSearchValue(new URLSearchParams(searchStr).get("published")); +} + function formatReportError(error: unknown) { if (error && typeof error === "object" && "data" in error) { const data = (error as { data?: unknown }).data; @@ -167,9 +179,12 @@ export function SkillDetailPage({ redirectToCanonical, initialData, mode = "detail", + showPostPublishSuccess = false, + onDismissPostPublish, }: SkillDetailPageProps) { const navigate = useNavigate(); const router = useRouter(); + const searchStr = useRouterState({ select: (state) => state.location.searchStr }); const { isAuthenticated, me } = useAuthStatus(); const { signIn } = useAuthActions(); const initialResult = initialData?.result ?? undefined; @@ -206,6 +221,7 @@ export function SkillDetailPage({ const [reportReason, setReportReason] = useState(""); const [reportError, setReportError] = useState(null); const [isSubmittingReport, setIsSubmittingReport] = useState(false); + const [hasClientPostPublishSearch, setHasClientPostPublishSearch] = useState(false); const [optimisticStar, setOptimisticStar] = useState<{ skillId: Id<"skills">; starred: boolean; @@ -253,6 +269,14 @@ export function SkillDetailPage({ const activeOptimisticStar = optimisticStar && skill && optimisticStar.skillId === skill._id ? optimisticStar : null; const effectiveIsStarred = activeOptimisticStar?.starred ?? isStarred; + + useEffect(() => { + const browserSearch = typeof window === "undefined" ? "" : window.location.search; + setHasClientPostPublishSearch( + hasPostPublishSearch(searchStr) || hasPostPublishSearch(browserSearch), + ); + }, [searchStr]); + const displayedSkill = useMemo(() => { if (!skill || !activeOptimisticStar) return skill; const currentStars = skill.stats.stars ?? 0; @@ -672,10 +696,13 @@ export function SkillDetailPage({ onSaveSummary={canAccessSettings ? submitSummary : null} /> ) : null; + const detailHref = buildSkillHref(ownerHandle, owner?._id ?? null, skill.slug); + const showPublishSuccessDialog = + mode === "detail" && + (showPostPublishSuccess || hasClientPostPublishSearch) && + Boolean(onDismissPostPublish); if (mode === "settings") { - const detailHref = buildSkillHref(ownerHandle, owner?._id ?? null, skill.slug); - return (
@@ -821,6 +848,26 @@ export function SkillDetailPage({ onCancel={closeReportDialog} onSubmit={() => void submitReport()} /> + undefined)} + />
); } diff --git a/src/components/SkillPublishSuccessDialog.test.tsx b/src/components/SkillPublishSuccessDialog.test.tsx new file mode 100644 index 0000000000..e2d9713edc --- /dev/null +++ b/src/components/SkillPublishSuccessDialog.test.tsx @@ -0,0 +1,123 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + OPENCLAW_SKILLS_DISCORD_URL, + SkillPublishSuccessDialog, +} from "./SkillPublishSuccessDialog"; + +const writeTextMock = vi.fn(); + +function renderDialog(overrides: Partial[0]> = {}) { + return render( + , + ); +} + +describe("SkillPublishSuccessDialog", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + writeTextMock.mockReset(); + writeTextMock.mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: writeTextMock, + }, + }); + Object.defineProperty(navigator, "share", { + configurable: true, + value: undefined, + }); + }); + + it("offers Discord, Twitter, and the published skill link", () => { + renderDialog(); + + expect(screen.getByRole("heading", { name: /It's alive!/i })).toBeTruthy(); + expect(screen.getAllByText("Agent Helper").length).toBeGreaterThan(0); + expect(screen.getByText("Vyctor")).toBeTruthy(); + expect(screen.queryByText("v1.0.0")).toBeNull(); + expect(screen.getByText("Developer tools")).toBeTruthy(); + expect(screen.getByText("#skills")).toBeTruthy(); + expect(screen.getByText("Friends of the Crustacean ๐Ÿฆž๐Ÿค")).toBeTruthy(); + + const discordLink = screen.getByRole("link", { name: /Share on Discord/i }); + expect(discordLink.getAttribute("href")).toBe(OPENCLAW_SKILLS_DISCORD_URL); + + const xLink = screen.getByRole("link", { name: /Share on Twitter/i }); + const xHref = xLink.getAttribute("href") ?? ""; + expect(xHref).toContain("https://twitter.com/intent/tweet?"); + const xParams = new URL(xHref).searchParams; + expect(xParams.get("text")).toBe( + "Agent Helper is now live on ClawHub ๐Ÿฆž Check it out: https://clawhub.ai/vyctor/agent-helper", + ); + expect(xParams.get("url")).toBeNull(); + }); + + it("moves focus into the dialog without highlighting a secondary action", async () => { + renderDialog(); + + const dialog = screen.getByRole("dialog"); + await waitFor(() => { + expect(document.activeElement).toBe(dialog); + }); + expect(screen.getByRole("button", { name: /Copy skill link/i })).not.toBe( + document.activeElement, + ); + }); + + it.each(["http://127.0.0.1:3030", "http://localhost:3030", "http://[::1]:3030"])( + "shares the public ClawHub URL instead of local dev origin %s", + (localOrigin) => { + vi.stubEnv("VITE_SITE_URL", localOrigin); + + renderDialog(); + + const skillLink = screen.getByRole("link", { name: "clawhub.ai/vyctor/agent-helper" }); + expect(skillLink.getAttribute("href")).toBe("https://clawhub.ai/vyctor/agent-helper"); + }, + ); + + it("copies the skill link from the inline copy action", async () => { + renderDialog(); + + fireEvent.click(screen.getByRole("button", { name: /Copy skill link/i })); + + await waitFor(() => { + expect(writeTextMock).toHaveBeenCalledWith(expect.stringContaining("/vyctor/agent-helper")); + }); + expect(screen.getByText("Copied")).toBeTruthy(); + }); + + it("copies a ready Discord message before opening the Discord channel", async () => { + renderDialog(); + + fireEvent.click(screen.getByRole("link", { name: /Share on Discord/i })); + + await waitFor(() => { + expect(writeTextMock).toHaveBeenCalledWith( + "I just published Agent Helper on ClawHub: https://clawhub.ai/vyctor/agent-helper", + ); + }); + }); + + it("dismisses from the View skill action", () => { + const onDismiss = vi.fn(); + renderDialog({ onDismiss }); + + fireEvent.click(screen.getByRole("button", { name: /View skill/i })); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/SkillPublishSuccessDialog.tsx b/src/components/SkillPublishSuccessDialog.tsx new file mode 100644 index 0000000000..9ecca7bdbb --- /dev/null +++ b/src/components/SkillPublishSuccessDialog.tsx @@ -0,0 +1,438 @@ +import { + ArrowRight, + Check, + Code2, + Copy, + ExternalLink, + FileText, + Package, + Wrench, +} from "lucide-react"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { getClawHubSiteUrl } from "../lib/site"; +import { parseSkillIcon } from "../lib/skillIcon"; +import { cn } from "../lib/utils"; +import { copyText } from "./InstallCopyButton"; +import { Button } from "./ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog"; + +export const OPENCLAW_SKILLS_DISCORD_URL = + "https://discord.com/channels/1456350064065904867/1456891440897724637"; +const PUBLIC_CLAWHUB_SITE_URL = "https://clawhub.ai"; +const LOCAL_SHARE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]); + +type CopyState = "idle" | "copied" | "failed"; + +type SkillPublishSuccessDialogProps = { + isOpen: boolean; + displayName: string; + skillPath: string; + skillIcon?: string | null; + publisher?: { + displayName?: string | null; + handle?: string | null; + image?: string | null; + kind?: string | null; + } | null; + categoryLabel?: string | null; + onDismiss: () => void; +}; + +function getPublicClawHubSiteUrl() { + const configured = getClawHubSiteUrl(); + try { + const hostname = new URL(configured).hostname; + if (LOCAL_SHARE_HOSTS.has(hostname)) return PUBLIC_CLAWHUB_SITE_URL; + } catch { + return PUBLIC_CLAWHUB_SITE_URL; + } + return configured; +} + +function buildAbsoluteSkillUrl(skillPath: string) { + return new URL(skillPath, getPublicClawHubSiteUrl()).toString(); +} + +function buildXShareUrl(displayName: string, skillUrl: string) { + const params = new URLSearchParams({ + text: `${displayName} is now live on ClawHub ๐Ÿฆž Check it out: ${skillUrl}`, + }); + return `https://twitter.com/intent/tweet?${params.toString()}`; +} + +function buildDiscordShareText(displayName: string, skillUrl: string) { + return `I just published ${displayName} on ClawHub: ${skillUrl}`; +} + +export function SkillPublishSuccessDialog({ + isOpen, + displayName, + skillPath, + skillIcon, + publisher, + categoryLabel, + onDismiss, +}: SkillPublishSuccessDialogProps) { + const [copyState, setCopyState] = useState("idle"); + const [dismissed, setDismissed] = useState(false); + const hasDismissedRef = useRef(false); + const dialogContentRef = useRef(null); + const skillUrl = useMemo(() => buildAbsoluteSkillUrl(skillPath), [skillPath]); + const compactSkillUrl = useMemo(() => skillUrl.replace(/^https?:\/\//, ""), [skillUrl]); + const xShareUrl = useMemo(() => buildXShareUrl(displayName, skillUrl), [displayName, skillUrl]); + const discordShareText = useMemo( + () => buildDiscordShareText(displayName, skillUrl), + [displayName, skillUrl], + ); + const publisherDisplayName = publisher?.displayName?.trim() || null; + const publisherHandle = publisher?.handle?.trim() || null; + const publisherLabel = publisherDisplayName || (publisherHandle ? `@${publisherHandle}` : null); + + useEffect(() => { + if (isOpen) { + setCopyState("idle"); + setDismissed(false); + hasDismissedRef.current = false; + } + }, [isOpen]); + + function dismiss() { + if (hasDismissedRef.current) return; + hasDismissedRef.current = true; + setDismissed(true); + onDismiss(); + } + + async function copySkillLink() { + try { + const didCopy = await copyText(skillUrl); + setCopyState(didCopy ? "copied" : "failed"); + } catch { + setCopyState("failed"); + } + } + + return ( + { + if (!open) dismiss(); + }} + > + { + event.preventDefault(); + dialogContentRef.current?.focus({ preventScroll: true }); + }} + onEscapeKeyDown={() => { + dismiss(); + }} + onInteractOutside={() => { + dismiss(); + }} + className="[--discord-accent:#5865F2] [--publish-accent:var(--status-success-fg)] [display:block] w-[min(calc(100vw-2rem),620px)] overflow-hidden rounded-[calc(var(--radius-md)+8px)] border-[color:color-mix(in_srgb,var(--publish-accent)_22%,var(--line))] bg-[color:var(--surface)] p-0 shadow-[0_34px_90px_-36px_rgba(0,0,0,0.62),0_0_0_1px_color-mix(in_srgb,var(--ink)_8%,transparent)] focus:outline-none sm:p-0" + style={{ display: "block" }} + > +
+
+ ); +} + +function DiscordIcon({ className, ...props }: React.SVGProps) { + return ( + + Discord + + + ); +} + +function XIcon({ className, ...props }: React.SVGProps) { + return ( + + X + + + ); +} + +function SkillDialogGlyph({ icon }: { icon?: string | null }) { + const parsed = parseSkillIcon(icon); + if (parsed?.kind === "url") { + return ( + + + + ); + } + + const Icon = parsed?.kind === "lucide" ? parsed.component : Package; + return ( + + + ); +} + +function ShareDivider({ orientation = "vertical" }: { orientation?: "horizontal" | "vertical" }) { + return ( +