Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DEFAULT_INVITE_TTL_SECS,
InviteLinkSection,
} from "./InviteLinkSection";
import { Separator } from "@/shared/ui/separator";

export function CommunityInviteDialog({
isOwner,
Expand All @@ -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 (
Expand All @@ -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>
Expand Down
176 changes: 137 additions & 39 deletions desktop/src/features/community-members/ui/InviteLinkSection.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 }[] = [
Expand All @@ -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,
Expand All @@ -50,31 +52,96 @@ 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;
const resetTimer = window.setTimeout(() => setCopyStatus("idle"), 2000);
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();
Comment thread
klopez4212 marked this conversation as resolved.
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 {
Expand All @@ -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>
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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>
);
}
85 changes: 80 additions & 5 deletions desktop/tests/e2e/invite-link-copy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");
Expand All @@ -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",
Expand Down Expand Up @@ -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 },
]);
});
Loading
Loading