diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx index c5cabc26a6..95500dd8a6 100644 --- a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx +++ b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx @@ -12,6 +12,7 @@ import { DEFAULT_INVITE_TTL_SECS, InviteLinkSection, } from "./InviteLinkSection"; +import { Separator } from "@/shared/ui/separator"; export function CommunityInviteDialog({ isOwner, @@ -25,7 +26,9 @@ export function CommunityInviteDialog({ const [ttlSecs, setTtlSecs] = React.useState(DEFAULT_INVITE_TTL_SECS); React.useEffect(() => { - if (open) setTtlSecs(DEFAULT_INVITE_TTL_SECS); + // Reset after the link section has unmounted so reopening never mints an + // invite with the previous dialog session's expiry. + if (!open) setTtlSecs(DEFAULT_INVITE_TTL_SECS); }, [open]); return ( @@ -49,10 +52,17 @@ export function CommunityInviteDialog({ /> +
+ + + Or, copy a link + +
+
-

- Link settings -

diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index 05b4687289..84232f26da 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -1,4 +1,5 @@ -import { Check, ChevronDown, Link2 } from "lucide-react"; +import { Check, ChevronDown } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { toast } from "sonner"; @@ -12,7 +13,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Separator } from "@/shared/ui/separator"; +import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; const TTL_OPTIONS: { label: string; value: number }[] = [ @@ -34,13 +35,14 @@ const MAX_USE_OPTIONS: { label: string; value: number | null }[] = [ export const DEFAULT_INVITE_TTL_SECS = TTL_OPTIONS[1].value; type CopyStatus = "idle" | "copying" | "copied"; +type GenerationStatus = "idle" | "generating" | "failed"; /** * Share-with-link footer for the community invite dialog. * - * Each copy action mints a fresh database-backed invite code and places its - * shareable landing-page URL on the clipboard. Invites may be unlimited or - * capped to a caller-selected number of successful joins. + * A database-backed invite link is minted when this section opens and whenever + * its settings change. Invites may be unlimited or capped to a caller-selected + * number of successful joins. */ export function InviteLinkSection({ onTtlSecsChange, @@ -50,18 +52,40 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); + const [generationStatus, setGenerationStatus] = + React.useState("generating"); + const [inviteUrl, setInviteUrl] = React.useState(""); const [maxUses, setMaxUses] = React.useState(null); + const generationRequestId = React.useRef(0); + // React StrictMode replays effects in development. Keep one in-flight mint + // per setting set so the replay observes the original request instead of + // creating a second durable invite. + const inviteRequests = React.useRef( + new Map>(), + ); + const shouldReduceMotion = useReducedMotion(); const ttlLabel = TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; const maxUsesLabel = MAX_USE_OPTIONS.find((option) => option.value === maxUses)?.label ?? "No limit"; - const copyLabel = - copyStatus === "copying" - ? "Copying…" - : copyStatus === "copied" - ? "Copied" - : "Copy link"; + const isGenerating = generationStatus === "generating"; + const hasGenerationFailed = generationStatus === "failed"; + const inviteSettingsKey = `${ttlSecs}:${maxUses ?? "no-limit"}`; + const isWorking = isGenerating || copyStatus === "copying"; + const copyLabel = hasGenerationFailed + ? "Retry" + : copyStatus === "copied" + ? "Copied" + : "Copy link"; + const copyButtonWidth = isWorking + ? "6.25rem" + : copyStatus === "copied" + ? "5.25rem" + : "4.5rem"; + const copyButtonTransition = shouldReduceMotion + ? { duration: 0 } + : { duration: 0.12, ease: [0.77, 0, 0.175, 1] as const }; React.useEffect(() => { if (copyStatus !== "copied") return; @@ -69,12 +93,55 @@ export function InviteLinkSection({ return () => window.clearTimeout(resetTimer); }, [copyStatus]); + const generateInviteLink = React.useCallback(async () => { + const requestId = generationRequestId.current + 1; + generationRequestId.current = requestId; + setGenerationStatus("generating"); + setInviteUrl(""); + setCopyStatus("idle"); + const existingRequest = inviteRequests.current.get(inviteSettingsKey); + const inviteRequest = existingRequest ?? mintInvite({ ttlSecs, maxUses }); + if (!existingRequest) { + inviteRequests.current.set(inviteSettingsKey, inviteRequest); + } + + try { + const invite = await inviteRequest; + if (inviteRequests.current.get(inviteSettingsKey) === inviteRequest) { + inviteRequests.current.delete(inviteSettingsKey); + } + if (generationRequestId.current === requestId) { + setInviteUrl(invite.url); + setGenerationStatus("idle"); + } + } catch { + if (inviteRequests.current.get(inviteSettingsKey) === inviteRequest) { + inviteRequests.current.delete(inviteSettingsKey); + } + if (generationRequestId.current === requestId) { + setGenerationStatus("failed"); + toast.error("Couldn’t create an invite link."); + } + } + }, [inviteSettingsKey, maxUses, ttlSecs]); + + React.useEffect(() => { + void generateInviteLink(); + return () => { + generationRequestId.current += 1; + }; + }, [generateInviteLink]); + + function retryInviteGeneration() { + if (!hasGenerationFailed) return; + void generateInviteLink(); + } + async function handleCopy() { - if (copyStatus === "copying") return; + if (!inviteUrl || isGenerating || copyStatus === "copying") return; setCopyStatus("copying"); try { - const invite = await mintInvite({ ttlSecs, maxUses }); - await writeTextToClipboard(invite.url); + await writeTextToClipboard(inviteUrl); setCopyStatus("copied"); toast.success("Invite link copied"); } catch { @@ -85,7 +152,60 @@ export function InviteLinkSection({ return (
-
+
+ + {inviteUrl ? ( + + ) : null} + + + +
+ +
Expires after @@ -94,7 +214,7 @@ export function InviteLinkSection({ aria-label="Choose invite expiry" className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground" data-testid="invite-link-ttl-trigger" - disabled={copyStatus === "copying"} + disabled={isGenerating || copyStatus === "copying"} size="sm" type="button" variant="ghost" @@ -129,7 +249,7 @@ export function InviteLinkSection({ aria-label="Choose maximum invite uses" className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground" data-testid="invite-link-max-uses-trigger" - disabled={copyStatus === "copying"} + disabled={isGenerating || copyStatus === "copying"} size="sm" type="button" variant="ghost" @@ -159,28 +279,6 @@ export function InviteLinkSection({
- -
- -
); } diff --git a/desktop/tests/e2e/invite-link-copy.spec.ts b/desktop/tests/e2e/invite-link-copy.spec.ts index 20df59a00c..353793ec4a 100644 --- a/desktop/tests/e2e/invite-link-copy.spec.ts +++ b/desktop/tests/e2e/invite-link-copy.spec.ts @@ -27,7 +27,7 @@ test.beforeEach(async ({ page }) => { }); }); -test("copies a freshly minted invite link without showing a URL or QR code", async ({ +test("copies a freshly minted invite link from the link field", async ({ page, }) => { await page.goto("/"); @@ -38,7 +38,9 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy await expect(page.getByTestId("member-pubkey-input")).toBeVisible(); await expect(page.getByTestId("member-role")).toHaveCount(0); await expect(page.getByTestId("confirm-add-member")).toHaveCount(0); - await expect(page.getByTestId("invite-link-url")).toHaveCount(0); + await expect(page.getByTestId("invite-link-url")).toHaveValue( + "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test", + ); await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0); await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText( "No limit", @@ -80,9 +82,82 @@ test("sets a selected invite-use limit", async ({ page }) => { ).toBeVisible(); await page.getByTestId("invite-link-max-uses-10").click(); await expect(maxUsesTrigger).toHaveText("10 uses"); + await expect + .poll(() => invitePayloads.at(-1)) + .toEqual({ + max_uses: 10, + ttl_secs: 3 * 24 * 60 * 60, + }); await page.getByTestId("copy-invite-link").click(); await expect(page.getByTestId("copy-invite-link")).toContainText("Copied"); - expect(invitePayloads).toEqual([ - { max_uses: 10, ttl_secs: 3 * 24 * 60 * 60 }, - ]); +}); + +test("retries a failed invite-link generation", async ({ page }) => { + let attempts = 0; + await page.route("**/api/invites", async (route) => { + attempts += 1; + if (attempts === 1) { + await route.fulfill({ status: 500 }); + return; + } + + await route.fulfill({ + contentType: "application/json", + json: { + code: "retry-test", + expires_at: Math.floor(Date.now() / 1000) + 86_400, + url: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=retry-test", + }, + status: 200, + }); + }); + + await page.goto("/"); + await openSettings(page, "community-members"); + await page.getByTestId("community-invite-dialog-trigger").click(); + + const linkField = page.getByTestId("invite-link-url"); + const copyButton = page.getByTestId("copy-invite-link"); + await expect(linkField).toHaveAttribute( + "placeholder", + "Couldn’t create invite link", + ); + await expect(copyButton).toHaveText("Retry"); + await expect(copyButton).toBeEnabled(); + + await copyButton.click(); + await expect(linkField).toHaveValue( + "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=retry-test", + ); + await expect(copyButton).toHaveText("Copy link"); + expect(attempts).toBe(2); +}); + +test("reopens with the default expiry before generating a new link", async ({ + page, +}) => { + await page.goto("/"); + await openSettings(page, "community-members"); + await page.getByTestId("community-invite-dialog-trigger").click(); + + await expect + .poll(() => invitePayloads) + .toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }]); + await page.getByTestId("invite-link-ttl-trigger").click(); + await page.getByTestId("invite-link-ttl-604800").click(); + await expect + .poll(() => invitePayloads) + .toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }, { ttl_secs: 7 * 24 * 60 * 60 }]); + + const dialog = page.getByTestId("community-invite-dialog"); + await dialog.getByRole("button", { name: "Close" }).click(); + await expect(dialog).toHaveCount(0); + await page.getByTestId("community-invite-dialog-trigger").click(); + await expect + .poll(() => invitePayloads) + .toEqual([ + { ttl_secs: 3 * 24 * 60 * 60 }, + { ttl_secs: 7 * 24 * 60 * 60 }, + { ttl_secs: 3 * 24 * 60 * 60 }, + ]); }); diff --git a/desktop/tests/e2e/invites-settings-screenshots.spec.ts b/desktop/tests/e2e/invites-settings-screenshots.spec.ts index e0856ac400..5421fa6428 100644 --- a/desktop/tests/e2e/invites-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/invites-settings-screenshots.spec.ts @@ -104,15 +104,19 @@ test("capture: share-style community invite dialog", async ({ page }) => { await expect( dialog.getByRole("heading", { name: "Add someone", exact: true }), ).toHaveCount(0); + await expect(dialog.getByTestId("invite-options-divider")).toBeVisible(); await expect( - dialog.getByText("Or share a link", { exact: true }), - ).toHaveCount(0); - await expect( - dialog.getByText("Link settings", { exact: true }), + dialog.getByText("Or, copy a link", { exact: true }), ).toBeVisible(); + await expect(dialog.getByText("Link settings", { exact: true })).toHaveCount( + 0, + ); await expect(page.getByTestId("member-pubkey-input")).toBeVisible(); await expect(page.getByTestId("member-role")).toHaveCount(0); await expect(page.getByTestId("confirm-add-member")).toHaveCount(0); + await expect(page.getByTestId("invite-link-url")).toHaveValue( + "https://alpha.example.com/invite/community-email-test", + ); await expect(page.getByTestId("copy-invite-link")).toHaveText("Copy link"); await expect(page.getByTestId("invite-link-ttl-trigger")).toHaveText( "3 days",