Refine community invite links (#4734)

## Summary

- Separate direct invites from link sharing with a labeled divider.
- Show the generated invite URL inline with truncation and a copy
control.
- Use shared loading feedback and a restrained copy-status resize.

## Validation

- `pnpm -C desktop exec playwright test
tests/e2e/invite-link-copy.spec.ts
tests/e2e/invites-settings-screenshots.spec.ts` (7 passed)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-08-04 18:39:09 +01:00
committed by GitHub
parent 0afeac8a7c
commit e1287c92cc
4 changed files with 239 additions and 52 deletions

View File

@@ -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({
/>
</section>
<div
className="relative flex items-center py-2"
data-testid="invite-options-divider"
>
<Separator className="bg-input/40" />
<span className="absolute left-1/2 -translate-x-1/2 bg-background px-3 text-sm text-muted-foreground">
Or, copy a link
</span>
</div>
<section className="space-y-3">
<p className="text-2xs font-medium text-secondary-foreground/75">
Link settings
</p>
<InviteLinkSection onTtlSecsChange={setTtlSecs} ttlSecs={ttlSecs} />
</section>
</DialogContent>

View File

@@ -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<CopyStatus>("idle");
const [generationStatus, setGenerationStatus] =
React.useState<GenerationStatus>("generating");
const [inviteUrl, setInviteUrl] = React.useState("");
const [maxUses, setMaxUses] = React.useState<number | null>(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<string, ReturnType<typeof mintInvite>>(),
);
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 (
<section data-testid="community-invite-link-section">
<div className="space-y-3">
<div className="relative">
<Input
aria-label="Community invite link"
className="h-11 pr-28 text-transparent caret-transparent selection:bg-transparent"
data-testid="invite-link-url"
disabled={isGenerating}
placeholder={
hasGenerationFailed
? "Couldn’t create invite link"
: "Creating invite link…"
}
readOnly
value={inviteUrl}
/>
{inviteUrl ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-3 right-28 flex items-center truncate text-sm text-muted-foreground"
data-testid="invite-link-preview"
>
{inviteUrl}
</span>
) : null}
<motion.div
className="absolute right-1 top-1"
animate={{ width: copyButtonWidth }}
initial={false}
transition={copyButtonTransition}
>
<Button
className="h-9 w-full px-3"
data-copy-status={copyStatus}
data-testid="copy-invite-link"
disabled={
!hasGenerationFailed &&
(isGenerating || !inviteUrl || copyStatus === "copying")
}
onClick={() =>
hasGenerationFailed ? retryInviteGeneration() : void handleCopy()
}
size="sm"
type="button"
>
{isWorking ? (
<Spinner aria-hidden="true" className="h-4 w-4 border-2" />
) : copyStatus === "copied" ? (
<Check aria-hidden="true" className="h-4 w-4" />
) : null}
{copyLabel}
</Button>
</motion.div>
</div>
<div className="mt-3 space-y-3">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">Expires after</span>
<DropdownMenu>
@@ -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({
</DropdownMenu>
</div>
</div>
<Separator className="my-4 bg-input/40" />
<div className="flex justify-end">
<Button
className="shrink-0 border-border shadow-none"
data-copy-status={copyStatus}
data-testid="copy-invite-link"
disabled={copyStatus === "copying"}
onClick={() => void handleCopy()}
size="sm"
type="button"
variant="outline"
>
{copyStatus === "copying" ? (
<Spinner aria-hidden="true" className="h-4 w-4 border-2" />
) : copyStatus === "copied" ? (
<Check aria-hidden="true" className="h-4 w-4" />
) : (
<Link2 aria-hidden="true" className="h-4 w-4" />
)}
{copyLabel}
</Button>
</div>
</section>
);
}

View File

@@ -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 },
]);
});

View File

@@ -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",