mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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. | |  |  | <details> <summary>Before: Invites page entry point</summary>  </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:
co-authored by
Joah Gerstenberg
Amp
Carl
kenny lopez
parent
7fb008f934
commit
310df2ec33
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user