diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx index bd23e2bbec..9daca590f4 100644 --- a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx +++ b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; @@ -29,12 +30,14 @@ export function CommunityInviteDialog({ return ( - + Invite to community + + Anyone with this link can join this community. + diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index c4e140f723..dc0735c85e 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -8,16 +8,12 @@ import { Button } from "@/shared/ui/button"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Input } from "@/shared/ui/input"; import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; -import { Switch } from "@/shared/ui/switch"; const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "1 day", value: 24 * 60 * 60 }, @@ -26,6 +22,15 @@ const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "30 days", value: 30 * 24 * 60 * 60 }, ]; +const MAX_USE_OPTIONS: { label: string; value: number | null }[] = [ + { label: "No limit", value: null }, + { label: "1 use", value: 1 }, + { label: "3 uses", value: 3 }, + { label: "5 uses", value: 5 }, + { label: "10 uses", value: 10 }, + { label: "25 uses", value: 25 }, +]; + export const DEFAULT_INVITE_TTL_SECS = TTL_OPTIONS[1].value; type CopyStatus = "idle" | "copying" | "copied"; @@ -45,16 +50,12 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); - const [maxUsesEnabled, setMaxUsesEnabled] = React.useState(true); - const [maxUsesInput, setMaxUsesInput] = React.useState("3"); - const parsedMaxUses = Number(maxUsesInput); - const maxUsesValid = - !maxUsesEnabled || - (Number.isInteger(parsedMaxUses) && - parsedMaxUses >= 1 && - parsedMaxUses <= 10000); + const [maxUses, setMaxUses] = React.useState(null); 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…" @@ -69,13 +70,10 @@ export function InviteLinkSection({ }, [copyStatus]); async function handleCopy() { - if (copyStatus === "copying" || !maxUsesValid) return; + if (copyStatus === "copying") return; setCopyStatus("copying"); try { - const invite = await mintInvite({ - ttlSecs, - maxUses: maxUsesEnabled ? parsedMaxUses : null, - }); + const invite = await mintInvite({ ttlSecs, maxUses }); await writeTextToClipboard(invite.url); setCopyStatus("copied"); toast.success("Invite link copied"); @@ -87,82 +85,79 @@ export function InviteLinkSection({ return (
-
- - -
-

Share with a link

-

- Anyone with the link can join this community. -

+
+
+ Expires after + + + + + + onTtlSecsChange(Number(value))} + value={String(ttlSecs)} + > + {TTL_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+
+ Limit number of uses + + + + + + + setMaxUses(value === "no-limit" ? null : Number(value)) + } + value={String(maxUses ?? "no-limit")} + > + {MAX_USE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
- - - - - - Expires after - - onTtlSecsChange(Number(value))} - value={String(ttlSecs)} - > - {TTL_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - -
-
- - - {maxUsesEnabled ? ( - setMaxUsesInput(event.target.value)} - placeholder="3" - type="number" - value={maxUsesInput} - /> - ) : null} - {maxUsesEnabled && !maxUsesValid ? ( - - Enter a whole number from 1 to 10,000 - - ) : null}
@@ -170,7 +165,7 @@ export function InviteLinkSection({ className="shrink-0 border-border shadow-none" data-copy-status={copyStatus} data-testid="copy-invite-link" - disabled={copyStatus === "copying" || !maxUsesValid} + disabled={copyStatus === "copying"} onClick={() => void handleCopy()} size="sm" type="button" diff --git a/desktop/tests/e2e/invite-link-copy.spec.ts b/desktop/tests/e2e/invite-link-copy.spec.ts index 94bc8e26b0..015abd848f 100644 --- a/desktop/tests/e2e/invite-link-copy.spec.ts +++ b/desktop/tests/e2e/invite-link-copy.spec.ts @@ -3,7 +3,10 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; +let invitePayloads: Record[]; + test.beforeEach(async ({ page }) => { + invitePayloads = []; await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: "http://127.0.0.1:4173", }); @@ -11,6 +14,7 @@ test.beforeEach(async ({ page }) => { relayRequiresMembership: true, }); await page.route("**/api/invites", async (route) => { + invitePayloads.push(route.request().postDataJSON()); await route.fulfill({ contentType: "application/json", json: { @@ -33,8 +37,12 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy await page.getByTestId("community-invite-dialog-trigger").click(); await expect(page.getByTestId("invite-link-url")).toHaveCount(0); await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0); + await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText( + "No limit", + ); await page.getByTestId("copy-invite-link").click(); await expect(page.getByTestId("copy-invite-link")).toContainText("Copied"); + expect(invitePayloads).toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }]); const payload = await page.evaluate(() => { const log = ( @@ -53,3 +61,25 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy text: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test", }); }); + +test("sets a selected invite-use limit", async ({ page }) => { + await page.goto("/"); + await openSettings(page, "community-members"); + await page.getByTestId("community-invite-dialog-trigger").click(); + + const maxUsesTrigger = page.getByTestId("invite-link-max-uses-trigger"); + await maxUsesTrigger.click(); + await expect( + page.getByRole("menuitemradio", { name: "No limit" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitemradio", { name: "25 uses" }), + ).toBeVisible(); + await page.getByTestId("invite-link-max-uses-10").click(); + await expect(maxUsesTrigger).toHaveText("10 uses"); + 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 654aec56fe..c56e33bcd5 100644 --- a/desktop/tests/e2e/invites-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/invites-settings-screenshots.spec.ts @@ -78,15 +78,25 @@ test("capture: share-style community invite dialog", async ({ page }) => { await expect(page.getByTestId("community-invite-email-field")).toHaveCount(0); await expect(page.getByPlaceholder("Type an email address")).toHaveCount(0); await expect( - dialog.getByRole("heading", { name: "Share with a link" }), + dialog.getByText("Anyone with this link can join this community."), ).toBeVisible(); + await expect(dialog.getByText("Expires after")).toBeVisible(); + await expect(dialog.getByText("Limit number of uses")).toBeVisible(); + await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText( + "No limit", + ); await expect(page.getByTestId("copy-invite-link")).toHaveText("Copy link"); await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0); await expect(page.getByTestId("invite-link-url")).toHaveCount(0); const expiryTrigger = page.getByTestId("invite-link-ttl-trigger"); await expect(expiryTrigger).toHaveText("3 days"); + await expect(expiryTrigger).toHaveCSS("font-size", "14px"); + await expect( + dialog.getByText("Limit number of uses", { exact: true }), + ).toHaveCSS("font-size", "14px"); await expiryTrigger.click(); + await expect(page.getByRole("menu")).not.toContainText("Expires after"); await expect( page.getByRole("menuitemradio", { name: "1 day" }), ).toBeVisible();