From 5d8929600503af6ee6b23ea7935d206c0181b7b7 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 16:04:20 +0100 Subject: [PATCH 1/3] feat(desktop): refine community invite links Signed-off-by: kenny lopez --- .../ui/CommunityInviteDialog.tsx | 14 +- .../ui/InviteLinkSection.tsx | 128 ++++++++++++------ desktop/tests/e2e/invite-link-copy.spec.ts | 15 +- .../e2e/invites-settings-screenshots.spec.ts | 12 +- 4 files changed, 118 insertions(+), 51 deletions(-) diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx index c5cabc26a6..6f8b4e8434 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, @@ -49,10 +50,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..635bf60bcb 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 }[] = [ @@ -38,9 +39,9 @@ type CopyStatus = "idle" | "copying" | "copied"; /** * 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 +51,21 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); + const [inviteUrl, setInviteUrl] = React.useState(""); + const [isGenerating, setIsGenerating] = React.useState(true); const [maxUses, setMaxUses] = React.useState(null); + 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 copyLabel = copyStatus === "copied" ? "Copied" : "Copy link"; + const isCopying = isGenerating || copyStatus === "copying"; + const copyButtonWidth = 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 +73,36 @@ export function InviteLinkSection({ return () => window.clearTimeout(resetTimer); }, [copyStatus]); + React.useEffect(() => { + let isCurrent = true; + + async function generateInviteLink() { + setIsGenerating(true); + setInviteUrl(""); + setCopyStatus("idle"); + try { + const invite = await mintInvite({ ttlSecs, maxUses }); + if (isCurrent) setInviteUrl(invite.url); + } catch { + if (isCurrent) { + toast.error("Couldn’t create an invite link. Try again."); + } + } finally { + if (isCurrent) setIsGenerating(false); + } + } + + void generateInviteLink(); + return () => { + isCurrent = false; + }; + }, [maxUses, ttlSecs]); + 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 +113,51 @@ export function InviteLinkSection({ return (
-
+
+ + {inviteUrl ? ( + + ) : null} + + + +
+ +
Expires after @@ -94,7 +166,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 +201,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 +231,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..5e63022a30 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,12 @@ 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 }, - ]); }); 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", From 352160b1e96e5ec706cac3b37402b3748d53fbf2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 17:37:26 +0100 Subject: [PATCH 2/3] fix(desktop): recover community invite links Signed-off-by: kenny lopez --- .../ui/CommunityInviteDialog.tsx | 4 +- .../ui/InviteLinkSection.tsx | 81 +++++++++++++------ desktop/tests/e2e/invite-link-copy.spec.ts | 70 ++++++++++++++++ 3 files changed, 128 insertions(+), 27 deletions(-) diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx index 6f8b4e8434..95500dd8a6 100644 --- a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx +++ b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx @@ -26,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 ( diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index 635bf60bcb..bda04c8097 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -35,6 +35,7 @@ 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. @@ -51,18 +52,30 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); + const [generationStatus, setGenerationStatus] = + React.useState("generating"); const [inviteUrl, setInviteUrl] = React.useState(""); - const [isGenerating, setIsGenerating] = React.useState(true); const [maxUses, setMaxUses] = React.useState(null); + const generationRequestId = React.useRef(0); 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 === "copied" ? "Copied" : "Copy link"; - const isCopying = isGenerating || copyStatus === "copying"; - const copyButtonWidth = copyStatus === "copied" ? "5.25rem" : "4.5rem"; + const isGenerating = generationStatus === "generating"; + const hasGenerationFailed = generationStatus === "failed"; + 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 }; @@ -73,30 +86,37 @@ export function InviteLinkSection({ return () => window.clearTimeout(resetTimer); }, [copyStatus]); - React.useEffect(() => { - let isCurrent = true; - - async function generateInviteLink() { - setIsGenerating(true); - setInviteUrl(""); - setCopyStatus("idle"); - try { - const invite = await mintInvite({ ttlSecs, maxUses }); - if (isCurrent) setInviteUrl(invite.url); - } catch { - if (isCurrent) { - toast.error("Couldn’t create an invite link. Try again."); - } - } finally { - if (isCurrent) setIsGenerating(false); + const generateInviteLink = React.useCallback(async () => { + const requestId = generationRequestId.current + 1; + generationRequestId.current = requestId; + setGenerationStatus("generating"); + setInviteUrl(""); + setCopyStatus("idle"); + try { + const invite = await mintInvite({ ttlSecs, maxUses }); + if (generationRequestId.current === requestId) { + setInviteUrl(invite.url); + setGenerationStatus("idle"); + } + } catch { + if (generationRequestId.current === requestId) { + setGenerationStatus("failed"); + toast.error("Couldn’t create an invite link."); } } + }, [maxUses, ttlSecs]); + React.useEffect(() => { void generateInviteLink(); return () => { - isCurrent = false; + generationRequestId.current += 1; }; - }, [maxUses, ttlSecs]); + }, [generateInviteLink]); + + function retryInviteGeneration() { + if (!hasGenerationFailed) return; + void generateInviteLink(); + } async function handleCopy() { if (!inviteUrl || isGenerating || copyStatus === "copying") return; @@ -119,7 +139,11 @@ export function InviteLinkSection({ className="h-11 pr-28 text-transparent caret-transparent selection:bg-transparent" data-testid="invite-link-url" disabled={isGenerating} - placeholder="Creating invite link…" + placeholder={ + hasGenerationFailed + ? "Couldn’t create invite link" + : "Creating invite link…" + } readOnly value={inviteUrl} /> @@ -142,12 +166,17 @@ export function InviteLinkSection({ className="h-9 w-full px-3" data-copy-status={copyStatus} data-testid="copy-invite-link" - disabled={isGenerating || !inviteUrl || copyStatus === "copying"} - onClick={() => void handleCopy()} + disabled={ + !hasGenerationFailed && + (isGenerating || !inviteUrl || copyStatus === "copying") + } + onClick={() => + hasGenerationFailed ? retryInviteGeneration() : void handleCopy() + } size="sm" type="button" > - {isCopying ? ( + {isWorking ? (