desktop: restore direct community member adds (#3634)

## Summary

- restore direct community member adds in **Settings → Invites**
- keep one consolidated entry point: the dialog now supports both adding
someone directly and sharing an invite link
- accept npub or 64-character hex keys while preserving role hierarchy
(owners: Member/Admin; admins: Member only)
- verify an npub direct-add publishes the decoded hex key in a kind
`9030` NIP-IA event

## Why

The Invites consolidation left `AddMemberDialog` without a live mount
point, so the existing direct-add capability disappeared even though its
mutation path still existed. This reuses that implementation rather than
introducing a second one.

## Before and after

| Before | After |
| --- | --- |
| The consolidated dialog only offered a share link; there was no
direct-add path. | The same dialog now presents direct add and
share-link controls as one invitation flow. |
| ![Before: invite dialog with share-link controls
only](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3634/before-invite-dialog.png)
| ![After: polished community invite dialog with direct-add and
share-link
sections](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3634/invite-dialog-polished.png)
|

<details>
<summary>Before: Invites page entry point</summary>

![Before: Invites settings
page](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3634/before-invites-page.png)

</details>

## Verification

- `pnpm --dir desktop build:e2e`
- `pnpm exec playwright test
tests/e2e/invites-settings-screenshots.spec.ts --project=smoke` — 4
passed
- targeted Biome check on modified files
- push hook: Desktop check and 3,783 Desktop tests passed
- GitHub CI green except Desktop E2E Relay still running at last status
snapshot

---------

Signed-off-by: Joah Gerstenberg <joah@squareup.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Joah Gerstenberg <joah@squareup.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Co-authored-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
Wes
2026-07-30 07:04:49 -07:00
committed by GitHub
co-authored by Joah Gerstenberg Amp Carl kenny lopez
parent 7fb008f934
commit 310df2ec33
11 changed files with 742 additions and 174 deletions
@@ -1,6 +1,7 @@
import { Search, UserPlus, X } from "lucide-react";
import * as React from "react";
import { parsePubkeyInput } from "@/shared/lib/nostrUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { PubKey } from "@/shared/ui/PubKey";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
@@ -105,6 +106,35 @@ export function ChannelMemberInviteCard({
],
);
// Someone without a kind:0 profile on the relay is invisible to user
// search — let the caller paste their npub or hex pubkey directly instead.
const directInvitee = React.useMemo<UserSearchResult | null>(() => {
const pubkey = parsePubkeyInput(deferredInviteQuery);
if (
pubkey === null ||
memberPubkeys.has(pubkey) ||
selectedInviteePubkeys.has(pubkey) ||
(userSearchQuery.data ?? []).some(
(user) => user.pubkey.toLowerCase() === pubkey,
)
) {
return null;
}
return {
pubkey,
displayName: null,
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
isAgent: false,
};
}, [
deferredInviteQuery,
memberPubkeys,
selectedInviteePubkeys,
userSearchQuery.data,
]);
React.useEffect(() => {
if (!open) {
setInviteQuery("");
@@ -161,7 +191,7 @@ export function ChannelMemberInviteCard({
disabled={isPending}
id="channel-management-search-users"
onChange={(event) => setInviteQuery(event.target.value)}
placeholder="Search people and agents"
placeholder="Search people, or paste a public key"
value={inviteQuery}
/>
</div>
@@ -217,12 +247,41 @@ export function ChannelMemberInviteCard({
) : null}
{deferredInviteQuery.length > 0 ? (
<div className="border-t border-border/70 px-2 py-2">
{userSearchQuery.isLoading ? (
{userSearchQuery.isLoading && !directInvitee ? (
<p className="px-2 py-1 text-sm text-muted-foreground">
Searching
</p>
) : inviteSearchResults.length > 0 ? (
) : inviteSearchResults.length > 0 || directInvitee ? (
<div className="max-h-44 space-y-1 overflow-y-auto">
{directInvitee ? (
<button
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
data-testid={`channel-direct-invite-${directInvitee.pubkey}`}
onClick={() => {
setSelectedInvitees((current) => [
...current,
directInvitee,
]);
setInviteQuery("");
}}
type="button"
>
<div className="flex min-w-0 items-center gap-2">
<UserAvatar
avatarUrl={null}
displayName={truncatePubkey(directInvitee.pubkey)}
size="xs"
/>
<p className="truncate text-sm font-medium leading-5">
{truncatePubkey(directInvitee.pubkey)}
</p>
<span className="shrink-0 text-xs text-muted-foreground">
by public key
</span>
</div>
<span className="text-xs text-muted-foreground">Add</span>
</button>
) : null}
{inviteSearchResults.map((result) => (
<button
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
@@ -1,3 +1,5 @@
import { ChevronDown, Search } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { toast } from "sonner";
@@ -5,8 +7,13 @@ import {
useAddRelayMemberMutation,
useRelayMembersQuery,
} from "@/features/community-members/hooks";
import type { RelayMemberRole } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useUserSearchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip";
import type { RelayMemberRole, UserSearchResult } from "@/shared/api/types";
import { parsePubkeyInput } from "@/shared/lib/nostrUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -15,15 +22,438 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
const PUBKEY_REGEX = /^[0-9a-f]{64}$/;
const ROLE_OPTIONS: Array<{ value: RelayMemberRole; label: string }> = [
{ value: "member", label: "Member" },
{ value: "admin", label: "Admin" },
const ROLE_OPTIONS: Array<{
value: RelayMemberRole;
label: string;
}> = [
{
value: "member",
label: "Member",
},
{
value: "admin",
label: "Admin",
},
];
function formatSearchUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
truncatePubkey(user.pubkey)
);
}
export function DirectAddMemberForm({
isOwner,
onAdded,
showLabel = true,
submitLabel = "Add member",
}: {
isOwner: boolean;
onAdded?: () => void;
showLabel?: boolean;
submitLabel?: string;
}) {
const addMutation = useAddRelayMemberMutation();
const membersQuery = useRelayMembersQuery();
const [query, setQuery] = React.useState("");
const [selectedUsers, setSelectedUsers] = React.useState<UserSearchResult[]>(
[],
);
const [role, setRole] = React.useState<RelayMemberRole>("member");
const [isPickerOpen, setIsPickerOpen] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const shouldReduceMotion = useReducedMotion();
const deferredQuery = React.useDeferredValue(query.trim());
const parsedPubkey = parsePubkeyInput(deferredQuery);
const userSearchQuery = useUserSearchQuery(deferredQuery, {
enabled: deferredQuery.length > 0,
limit: 8,
});
const isArchived = useIsArchivedPredicate();
const selectedPubkeys = React.useMemo(
() => new Set(selectedUsers.map((user) => user.pubkey.toLowerCase())),
[selectedUsers],
);
const isAlreadyMember =
parsedPubkey !== null &&
(membersQuery.data ?? []).some(
(m) => m.pubkey.toLowerCase() === parsedPubkey.toLowerCase(),
);
const canAdd = selectedUsers.length > 0 && !addMutation.isPending;
const searchResults = React.useMemo(
() =>
(userSearchQuery.data ?? []).filter(
(user) =>
!isArchived(user.pubkey) &&
!selectedPubkeys.has(user.pubkey.toLowerCase()) &&
!(membersQuery.data ?? []).some(
(member) =>
member.pubkey.toLowerCase() === user.pubkey.toLowerCase(),
),
),
[isArchived, membersQuery.data, selectedPubkeys, userSearchQuery.data],
);
const directResult = React.useMemo<UserSearchResult | null>(() => {
if (
parsedPubkey === null ||
isAlreadyMember ||
selectedPubkeys.has(parsedPubkey.toLowerCase()) ||
searchResults.some(
(user) => user.pubkey.toLowerCase() === parsedPubkey.toLowerCase(),
)
) {
return null;
}
return {
pubkey: parsedPubkey,
displayName: null,
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
isAgent: false,
};
}, [isAlreadyMember, parsedPubkey, searchResults, selectedPubkeys]);
const roleOptions = React.useMemo(
() => ROLE_OPTIONS.filter((option) => isOwner || option.value === "member"),
[isOwner],
);
const selectedRoleLabel =
roleOptions.find((option) => option.value === role)?.label ?? "Member";
const actionTransition = shouldReduceMotion
? { duration: 0 }
: { duration: 0.18, ease: [0.23, 1, 0.32, 1] as const };
function reset() {
setQuery("");
setSelectedUsers([]);
setRole("member");
setIsPickerOpen(false);
addMutation.reset();
}
function selectUser(user: UserSearchResult) {
setSelectedUsers((currentUsers) =>
currentUsers.some(
(currentUser) =>
currentUser.pubkey.toLowerCase() === user.pubkey.toLowerCase(),
)
? currentUsers
: [...currentUsers, user],
);
setQuery("");
setIsPickerOpen(true);
window.requestAnimationFrame(() => {
searchInputRef.current?.focus({ preventScroll: true });
});
}
function removeUser(pubkey: string) {
setSelectedUsers((currentUsers) =>
currentUsers.filter(
(user) => user.pubkey.toLowerCase() !== pubkey.toLowerCase(),
),
);
searchInputRef.current?.focus({ preventScroll: true });
}
async function handleAdd() {
if (!canAdd) return;
try {
for (const user of selectedUsers) {
await addMutation.mutateAsync({ pubkey: user.pubkey, role });
}
toast.success(
selectedUsers.length === 1
? role === "admin"
? "Admin added"
: "Member added"
: role === "admin"
? "Admins added"
: "Members added",
);
reset();
onAdded?.();
} catch {
// The mutation exposes the API error below the field.
}
}
return (
<form
className="space-y-4"
data-testid="direct-add-member-form"
onSubmit={(event) => {
event.preventDefault();
handleAdd();
}}
>
<div className="space-y-1.5">
{showLabel ? (
<label className="text-sm font-medium" htmlFor="member-search">
Person
</label>
) : null}
<div className="flex gap-2">
<Popover
modal={false}
onOpenChange={setIsPickerOpen}
open={isPickerOpen && deferredQuery.length > 0}
>
<PopoverAnchor asChild>
<div
className="min-w-0 flex-1 rounded-md border border-input bg-background focus-within:ring-1 focus-within:ring-ring"
data-testid="member-recipient-field"
>
<div
className={`grid min-h-10 min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-3 p-1.5 ${selectedUsers.length > 1 ? "items-start" : "items-center"}`}
>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{selectedUsers.length === 0 ? (
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55" />
) : null}
{selectedUsers.map((user) => (
<motion.div
key={user.pubkey}
layout="position"
transition={actionTransition}
>
<SelectedRecipientChip
disabled={addMutation.isPending}
inspectable={false}
label={formatSearchUserName(user)}
onRemove={() => removeUser(user.pubkey)}
poofOnRemove={false}
testIds={{
chip: `member-search-selection-remove-${user.pubkey}`,
}}
user={user}
/>
</motion.div>
))}
<Input
aria-autocomplete="list"
aria-controls="member-search-results"
aria-expanded={isPickerOpen}
autoCapitalize="none"
autoCorrect="off"
className="h-7 w-auto min-w-16 flex-1 border-0 bg-transparent px-0 py-0.5 text-sm shadow-none outline-hidden placeholder:text-muted-foreground/55 focus-visible:ring-0"
data-testid="member-pubkey-input"
disabled={addMutation.isPending}
id="member-search"
onChange={(event) => {
setQuery(event.target.value);
setIsPickerOpen(true);
}}
onFocus={() => setIsPickerOpen(true)}
onKeyDown={(event) => {
if (
event.key === "Backspace" &&
query.length === 0 &&
selectedUsers.length > 0
) {
event.preventDefault();
const lastUser = selectedUsers.at(-1);
if (lastUser) removeUser(lastUser.pubkey);
}
}}
placeholder={
selectedUsers.length === 0
? "Search people or paste an npub"
: ""
}
ref={searchInputRef}
role="combobox"
spellCheck={false}
value={query}
/>
</div>
<AnimatePresence initial={false}>
{selectedUsers.length > 0 ? (
<motion.div
animate={{ opacity: 1, scale: 1, x: 0 }}
className="shrink-0"
exit={{ opacity: 0, scale: 0.96, x: -4 }}
initial={{ opacity: 0, scale: 0.96, x: -4 }}
transition={actionTransition}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-label="Choose member role"
className="inline-flex items-center gap-1.5 bg-transparent text-sm text-muted-foreground outline-hidden transition-colors hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
data-testid="member-role"
disabled={addMutation.isPending}
type="button"
>
{selectedRoleLabel}
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
sideOffset={4}
style={{ minWidth: "13rem" }}
>
<DropdownMenuRadioGroup
onValueChange={(value) =>
setRole(value as RelayMemberRole)
}
value={role}
>
{roleOptions.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) overflow-hidden p-0"
data-testid="member-search-popover"
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
sideOffset={6}
>
<div
className="max-h-64 overflow-y-auto overscroll-contain py-1"
data-testid="member-search-results"
id="member-search-results"
role="listbox"
>
{userSearchQuery.isLoading ? (
<p className="px-3 py-3 text-sm text-muted-foreground">
Searching
</p>
) : searchResults.length > 0 || directResult ? (
<>
{directResult ? (
<SearchResult
onSelect={() => selectUser(directResult)}
user={directResult}
/>
) : null}
{searchResults.map((user) => (
<SearchResult
key={user.pubkey}
onSelect={() => selectUser(user)}
user={user}
/>
))}
</>
) : (
<p className="px-3 py-3 text-sm text-muted-foreground">
No people found. Paste a full npub or hex public key to add
someone directly.
</p>
)}
</div>
</PopoverContent>
</Popover>
<AnimatePresence initial={false}>
{selectedUsers.length > 0 ? (
<motion.div
animate={{ opacity: 1, scale: 1, x: 0 }}
className="shrink-0"
exit={{ opacity: 0, scale: 0.96, x: -4 }}
initial={{ opacity: 0, scale: 0.96, x: -4 }}
transition={actionTransition}
>
<Button
className="h-11"
data-testid="confirm-add-member"
disabled={!canAdd}
size="sm"
type="submit"
>
{addMutation.isPending ? "Inviting…" : submitLabel}
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
{isAlreadyMember ? (
<p className="text-xs text-destructive">
This person is already a community member.
</p>
) : null}
{userSearchQuery.error instanceof Error ? (
<p className="text-xs text-destructive">
{userSearchQuery.error.message}
</p>
) : null}
</div>
{addMutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{addMutation.error.message}
</p>
) : null}
</form>
);
}
function SearchResult({
onSelect,
user,
}: {
onSelect: () => void;
user: UserSearchResult;
}) {
const name = formatSearchUserName(user);
const isDirectPubkey = user.displayName === null && user.nip05Handle === null;
return (
<button
className="flex min-h-11 w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-hidden"
data-testid={`member-search-result-${user.pubkey}`}
onClick={onSelect}
role="option"
type="button"
>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-8 w-8 text-xs shadow-none"
iconClassName="h-4 w-4"
label={name}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{name}
</span>
{isDirectPubkey ? (
<span className="shrink-0 text-xs text-muted-foreground">
public key
</span>
) : null}
</button>
);
}
export function AddMemberDialog({
isOwner,
open,
@@ -33,49 +463,8 @@ export function AddMemberDialog({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const addMutation = useAddRelayMemberMutation();
const membersQuery = useRelayMembersQuery();
const [pubkey, setPubkey] = React.useState("");
const [role, setRole] = React.useState<RelayMemberRole>("member");
const normalizedPubkey = pubkey.trim().toLowerCase();
const isValidPubkey = PUBKEY_REGEX.test(normalizedPubkey);
const isAlreadyMember =
isValidPubkey &&
!addMutation.isPending &&
(membersQuery.data ?? []).some(
(m) => m.pubkey.toLowerCase() === normalizedPubkey,
);
const canAdd = isValidPubkey && !isAlreadyMember && !addMutation.isPending;
function reset() {
setPubkey("");
setRole("member");
addMutation.reset();
}
function handleOpenChange(next: boolean) {
if (!next) {
reset();
}
onOpenChange(next);
}
function handleAdd() {
if (!canAdd) return;
addMutation.mutate(
{ pubkey: normalizedPubkey, role },
{
onSuccess: () => {
toast.success("Member added");
handleOpenChange(false);
},
},
);
}
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="max-w-md overflow-hidden p-0"
data-testid="add-relay-member-dialog"
@@ -84,102 +473,15 @@ export function AddMemberDialog({
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Add member</DialogTitle>
<DialogDescription>
Add a user to this relay by their public key.
Add a person to this community by their public key.
</DialogDescription>
</DialogHeader>
<form
className="contents"
onSubmit={(e) => {
e.preventDefault();
handleAdd();
}}
>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-4">
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="member-pubkey"
>
Public key
</label>
<Input
autoCapitalize="none"
autoCorrect="off"
data-testid="member-pubkey-input"
id="member-pubkey"
maxLength={64}
onChange={(e) => setPubkey(e.target.value)}
placeholder="64-character hex pubkey"
spellCheck={false}
value={pubkey}
/>
{pubkey.trim().length > 0 && !isValidPubkey ? (
<p className="text-xs text-destructive">
Must be exactly 64 lowercase hex characters.
</p>
) : null}
{isAlreadyMember ? (
<p className="text-xs text-destructive">
This pubkey is already a relay member.
</p>
) : null}
</div>
<div className="space-y-1.5">
<p className="text-sm font-medium">Role</p>
<div className="flex gap-2">
{ROLE_OPTIONS.filter(
(opt) => isOwner || opt.value === "member",
).map((opt) => (
<button
aria-pressed={role === opt.value}
className={cn(
"rounded-lg border px-3 py-1.5 text-sm transition-colors",
role === opt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 text-muted-foreground hover:bg-accent",
)}
data-testid={`member-role-${opt.value}`}
key={opt.value}
onClick={() => setRole(opt.value)}
type="button"
>
{opt.label}
</button>
))}
</div>
</div>
{addMutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{addMutation.error.message}
</p>
) : null}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-border/60 bg-background/95 px-6 py-4">
<Button
data-testid="cancel-add-member"
onClick={() => handleOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="confirm-add-member"
disabled={!canAdd}
size="sm"
type="submit"
>
{addMutation.isPending ? "Adding..." : "Add member"}
</Button>
</div>
</form>
<div className="px-6 py-4">
<DirectAddMemberForm
isOwner={isOwner}
onAdded={() => onOpenChange(false)}
/>
</div>
</div>
</DialogContent>
</Dialog>
@@ -7,20 +7,21 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { DirectAddMemberForm } from "./AddMemberDialog";
import {
DEFAULT_INVITE_TTL_SECS,
InviteLinkSection,
} from "./InviteLinkSection";
export function CommunityInviteDialog({
isOwner,
onOpenChange,
open,
}: {
isOwner: boolean;
onOpenChange: (open: boolean) => void;
open: boolean;
}) {
// Email delivery is not available yet, so the modal only mints shareable
// invite links through the relay's existing invite flow.
const [ttlSecs, setTtlSecs] = React.useState(DEFAULT_INVITE_TTL_SECS);
React.useEffect(() => {
@@ -36,11 +37,24 @@ export function CommunityInviteDialog({
<DialogHeader>
<DialogTitle>Invite to community</DialogTitle>
<DialogDescription>
Anyone with this link can join this community.
Add someone directly or share a link they can use to join.
</DialogDescription>
</DialogHeader>
<InviteLinkSection onTtlSecsChange={setTtlSecs} ttlSecs={ttlSecs} />
<section className="mt-2 space-y-3">
<DirectAddMemberForm
isOwner={isOwner}
showLabel={false}
submitLabel="Invite"
/>
</section>
<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>
</Dialog>
);
@@ -370,6 +370,7 @@ export function CommunityMembersSettingsCard({
</div>
<CommunityInviteDialog
isOwner={currentRole === "owner"}
onOpenChange={setInviteDialogOpen}
open={inviteDialogOpen}
/>
@@ -84,8 +84,8 @@ export function InviteLinkSection({
}
return (
<section className="pt-2" data-testid="community-invite-link-section">
<div className="space-y-5">
<section data-testid="community-invite-link-section">
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">Expires after</span>
<DropdownMenu>
@@ -46,7 +46,7 @@ export function SelectedRecipientChip({
user: UserSearchResult;
}) {
return (
<div className="inline-flex h-7 max-w-56 items-center gap-1.5 rounded-full bg-muted px-1.5 pr-2.5 text-sm transition-colors hover:bg-muted/80">
<div className="inline-flex h-7 max-w-56 items-center gap-1.5 rounded-full bg-muted px-1 pr-2.5 text-sm transition-colors hover:bg-muted/80">
<button
aria-label={`Remove ${label}`}
className={cn(
+28
View File
@@ -23,6 +23,34 @@ export function safeNpub(pubkey: string): string | null {
}
}
const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/;
/**
* Parse user-entered public key input either a 64-character hex pubkey or
* a bech32 `npub1…` string into a lowercase hex pubkey. Returns null for
* anything else (does NOT throw intended for live form validation).
*
* The input is trimmed first; surrounding whitespace from copy-paste is
* tolerated.
*/
export function parsePubkeyInput(input: string): string | null {
const trimmed = input.trim().toLowerCase();
if (HEX_PUBKEY_REGEX.test(trimmed)) {
return trimmed;
}
if (trimmed.startsWith("npub1")) {
try {
const decoded = decode(trimmed);
if (decoded.type === "npub") {
return decoded.data;
}
} catch {
return null;
}
}
return null;
}
/**
* Decode a bech32 nsec string and derive the matching npub. Returns null if
* the input is not a syntactically valid `nsec1…` (does NOT throw this is
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parsePubkeyInput } from "./nostrUtils.ts";
const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";
describe("parsePubkeyInput", () => {
it("accepts a lowercase 64-char hex pubkey", () => {
assert.equal(parsePubkeyInput(HEX), HEX);
});
it("lowercases an uppercase hex pubkey", () => {
assert.equal(parsePubkeyInput(HEX.toUpperCase()), HEX);
});
it("decodes an npub to its hex pubkey", () => {
assert.equal(parsePubkeyInput(NPUB), HEX);
});
it("tolerates surrounding whitespace from copy-paste", () => {
assert.equal(parsePubkeyInput(` ${NPUB}\n`), HEX);
assert.equal(parsePubkeyInput(` ${HEX} `), HEX);
});
it("rejects an npub with a corrupted checksum", () => {
assert.equal(parsePubkeyInput(`${NPUB.slice(0, -1)}q`), null);
});
it("rejects other bech32 entities such as nsec", () => {
assert.equal(
parsePubkeyInput(
"nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
),
null,
);
});
it("rejects hex of the wrong length", () => {
assert.equal(parsePubkeyInput(HEX.slice(0, 63)), null);
assert.equal(parsePubkeyInput(`${HEX}0`), null);
});
it("rejects non-hex non-npub input", () => {
assert.equal(parsePubkeyInput(""), null);
assert.equal(parsePubkeyInput("alice"), null);
assert.equal(parsePubkeyInput(`z${HEX.slice(1)}`), null);
});
});
+1
View File
@@ -145,6 +145,7 @@ test.describe("community rail", () => {
await expect(page.getByTestId("community-invite-email-field")).toHaveCount(
0,
);
await expect(page.getByTestId("member-pubkey-input")).toBeVisible();
await expect(page.getByTestId("copy-invite-link")).toBeVisible();
});
@@ -35,6 +35,9 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy
await expect(page.getByTestId("settings-community-members")).toBeVisible();
await page.getByTestId("community-invite-dialog-trigger").click();
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-qr-code")).toHaveCount(0);
await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText(
@@ -5,11 +5,21 @@ import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const OUTDIR = "test-results/invites-settings";
const DIRECT_ADD_HEX =
"ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
const DIRECT_ADD_NPUB =
"npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";
const SECOND_DIRECT_ADD_HEX =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const SECOND_DIRECT_ADD_NPUB =
"npub1424242424242424242424242424242424242424242424242424qamrcaj";
test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ page }, testInfo) => {
await installMockBridge(page, {
relayRequiresMembership: true,
relayRole: "owner",
relayRole: testInfo.title.includes("admin can add members")
? "admin"
: "owner",
});
await page.route("**/api/invites", async (route) => {
await route.fulfill({
@@ -78,33 +88,133 @@ 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.getByText("Anyone with this link can join this community."),
dialog.getByText(
"Add someone directly or share a link they can use to join.",
),
).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(
dialog.getByRole("heading", { name: "Add someone", exact: true }),
).toHaveCount(0);
await expect(
dialog.getByText("Or share a link", { exact: true }),
).toHaveCount(0);
await expect(
dialog.getByText("Link settings", { exact: true }),
).toBeVisible();
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("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);
await expect(page.getByTestId("invite-link-ttl-trigger")).toHaveText(
"3 days",
);
const expiryTrigger = page.getByTestId("invite-link-ttl-trigger");
await expect(expiryTrigger).toHaveText("3 days");
await expect(expiryTrigger).toHaveCSS("font-size", "14px");
await page.getByTestId("member-pubkey-input").fill(DIRECT_ADD_NPUB);
await expect(page.getByTestId("member-search-popover")).toBeVisible();
await page.getByTestId(`member-search-result-${DIRECT_ADD_HEX}`).click();
const memberRole = page.getByTestId("member-role");
const selectedChip = page.getByTestId(
`member-search-selection-remove-${DIRECT_ADD_HEX}`,
);
await expect(memberRole).toHaveText("Member");
const inviteButton = page.getByTestId("confirm-add-member");
await expect(inviteButton).toHaveText("Invite");
await waitForAnimations(page);
await expect(inviteButton).toHaveCSS("height", "44px");
await expect(inviteButton).toHaveJSProperty(
"offsetHeight",
await page
.getByTestId("member-recipient-field")
.evaluate((field) => Math.round(field.getBoundingClientRect().height)),
);
const selectedChipRemoveIcon = selectedChip.locator("span.absolute");
await expect(selectedChipRemoveIcon).toHaveCSS("opacity", "0");
await selectedChip.hover();
await expect(selectedChipRemoveIcon).toHaveCSS("opacity", "1");
const memberSearch = page.getByTestId("member-pubkey-input");
await expect(memberSearch).toBeFocused();
await memberSearch.fill(SECOND_DIRECT_ADD_NPUB);
await expect(page.getByTestId("member-search-popover")).toBeVisible();
await page
.getByTestId(`member-search-result-${SECOND_DIRECT_ADD_HEX}`)
.click();
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" }),
page.getByTestId(`member-search-selection-remove-${SECOND_DIRECT_ADD_HEX}`),
).toBeVisible();
await expect(memberSearch).toBeFocused();
await memberRole.click();
await expect(
page.getByRole("menuitemradio", { name: "30 days" }),
page.getByRole("menuitemradio", { name: "Admin" }),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("confirm-add-member")).toBeEnabled();
await waitForAnimations(page);
await page.mouse.move(0, 0);
await dialog.screenshot({ path: `${OUTDIR}/02-invite-dialog.png` });
});
test("admin can add members but cannot assign the admin role", async ({
page,
}) => {
await page.getByTestId("community-invite-dialog-trigger").click();
await page.getByTestId("member-pubkey-input").fill(DIRECT_ADD_NPUB);
await page.getByTestId(`member-search-result-${DIRECT_ADD_HEX}`).click();
const memberRole = page.getByTestId("member-role");
await expect(memberRole).toHaveText("Member");
await memberRole.click();
await expect(page.getByRole("menuitemradio", { name: "Admin" })).toHaveCount(
0,
);
await page.keyboard.press("Escape");
});
test("owner can add multiple admins directly by npub from live Invites UI", async ({
page,
}) => {
await page.getByTestId("community-invite-dialog-trigger").click();
await page.getByTestId("member-pubkey-input").fill(DIRECT_ADD_NPUB);
await page.getByTestId(`member-search-result-${DIRECT_ADD_HEX}`).click();
await page.getByTestId("member-pubkey-input").fill(SECOND_DIRECT_ADD_NPUB);
await page
.getByTestId(`member-search-result-${SECOND_DIRECT_ADD_HEX}`)
.click();
await page.getByTestId("member-role").click();
await page.getByRole("menuitemradio", { name: "Admin" }).click();
await page.getByTestId("confirm-add-member").click();
await expect
.poll(async () =>
page.evaluate(
({ targetPubkeys, role }) =>
targetPubkeys.every((targetPubkey) =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some((entry) => {
if (entry.command !== "plugin:websocket|send") return false;
const wireMessage = (
entry.payload as {
message?: { data?: unknown };
}
)?.message?.data;
if (typeof wireMessage !== "string") return false;
const message = JSON.parse(wireMessage) as unknown[];
if (message[0] !== "EVENT") return false;
const event = message[1] as
| { kind?: number; tags?: string[][] }
| undefined;
return (
event?.kind === 9030 &&
event.tags?.some(
(tag) => tag[0] === "p" && tag[1] === targetPubkey,
) &&
event.tags.some((tag) => tag[0] === "role" && tag[1] === role)
);
}),
),
{
targetPubkeys: [DIRECT_ADD_HEX, SECOND_DIRECT_ADD_HEX],
role: "admin",
},
),
)
.toBe(true);
});