mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Refine community invite limits (#3529)
## Summary - Simplify the community invite dialog around link sharing. - Add matching expiry and use-limit dropdowns, with sensible preset use caps. - Cover the default unlimited and selected-limit invite payloads. ## Validation - `pnpm -C desktop run build:e2e` - `pnpm -C desktop exec playwright test tests/e2e/invite-link-copy.spec.ts tests/e2e/invites-settings-screenshots.spec.ts --project=smoke` Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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 (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className="max-h-[85vh] max-w-xl overflow-y-auto"
|
||||
data-testid="community-invite-dialog"
|
||||
>
|
||||
<DialogHeader className="space-y-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite to community</DialogTitle>
|
||||
<DialogDescription>
|
||||
Anyone with this link can join this community.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<InviteLinkSection onTtlSecsChange={setTtlSecs} ttlSecs={ttlSecs} />
|
||||
|
||||
@@ -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<CopyStatus>("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<number | null>(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 (
|
||||
<section className="pt-2" data-testid="community-invite-link-section">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Link2 aria-hidden="true" className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium">Share with a link</h3>
|
||||
<p className="text-xs text-secondary-foreground/75">
|
||||
Anyone with the link can join this community.
|
||||
</p>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">Expires after</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
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"}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{ttlLabel}
|
||||
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => onTtlSecsChange(Number(value))}
|
||||
value={String(ttlSecs)}
|
||||
>
|
||||
{TTL_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`invite-link-ttl-${option.value}`}
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">Limit number of uses</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
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"}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{maxUsesLabel}
|
||||
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) =>
|
||||
setMaxUses(value === "no-limit" ? null : Number(value))
|
||||
}
|
||||
value={String(maxUses ?? "no-limit")}
|
||||
>
|
||||
{MAX_USE_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`invite-link-max-uses-${option.value ?? "no-limit"}`}
|
||||
key={option.value ?? "no-limit"}
|
||||
value={String(option.value ?? "no-limit")}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Choose invite expiry"
|
||||
className="h-8 shrink-0 gap-1.5 px-2 text-muted-foreground"
|
||||
data-testid="invite-link-ttl-trigger"
|
||||
disabled={copyStatus === "copying"}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{ttlLabel}
|
||||
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuLabel>Expires after</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => onTtlSecsChange(Number(value))}
|
||||
value={String(ttlSecs)}
|
||||
>
|
||||
{TTL_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`invite-link-ttl-${option.value}`}
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs">
|
||||
<Switch
|
||||
checked={maxUsesEnabled}
|
||||
data-testid="invite-link-max-uses-switch"
|
||||
id="invite-max-uses"
|
||||
onCheckedChange={setMaxUsesEnabled}
|
||||
/>
|
||||
<label className="text-muted-foreground" htmlFor="invite-max-uses">
|
||||
Limit uses
|
||||
</label>
|
||||
{maxUsesEnabled ? (
|
||||
<Input
|
||||
className="h-7 w-20"
|
||||
data-testid="invite-link-max-uses-input"
|
||||
inputMode="numeric"
|
||||
max={10000}
|
||||
min={1}
|
||||
onChange={(event) => setMaxUsesInput(event.target.value)}
|
||||
placeholder="3"
|
||||
type="number"
|
||||
value={maxUsesInput}
|
||||
/>
|
||||
) : null}
|
||||
{maxUsesEnabled && !maxUsesValid ? (
|
||||
<span
|
||||
className="text-destructive"
|
||||
data-testid="invite-link-max-uses-error"
|
||||
>
|
||||
Enter a whole number from 1 to 10,000
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Separator className="my-4 bg-input/40" />
|
||||
<div className="flex justify-end">
|
||||
@@ -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"
|
||||
|
||||
@@ -3,7 +3,10 @@ import { expect, test } from "@playwright/test";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
|
||||
let invitePayloads: Record<string, unknown>[];
|
||||
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user