mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00: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:
@@ -1,6 +1,7 @@
|
|||||||
import { Search, UserPlus, X } from "lucide-react";
|
import { Search, UserPlus, X } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { parsePubkeyInput } from "@/shared/lib/nostrUtils";
|
||||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||||
import { PubKey } from "@/shared/ui/PubKey";
|
import { PubKey } from "@/shared/ui/PubKey";
|
||||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setInviteQuery("");
|
setInviteQuery("");
|
||||||
@@ -161,7 +191,7 @@ export function ChannelMemberInviteCard({
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
id="channel-management-search-users"
|
id="channel-management-search-users"
|
||||||
onChange={(event) => setInviteQuery(event.target.value)}
|
onChange={(event) => setInviteQuery(event.target.value)}
|
||||||
placeholder="Search people and agents"
|
placeholder="Search people, or paste a public key"
|
||||||
value={inviteQuery}
|
value={inviteQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,12 +247,41 @@ export function ChannelMemberInviteCard({
|
|||||||
) : null}
|
) : null}
|
||||||
{deferredInviteQuery.length > 0 ? (
|
{deferredInviteQuery.length > 0 ? (
|
||||||
<div className="border-t border-border/70 px-2 py-2">
|
<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">
|
<p className="px-2 py-1 text-sm text-muted-foreground">
|
||||||
Searching…
|
Searching…
|
||||||
</p>
|
</p>
|
||||||
) : inviteSearchResults.length > 0 ? (
|
) : inviteSearchResults.length > 0 || directInvitee ? (
|
||||||
<div className="max-h-44 space-y-1 overflow-y-auto">
|
<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) => (
|
{inviteSearchResults.map((result) => (
|
||||||
<button
|
<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"
|
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 * as React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -5,8 +7,13 @@ import {
|
|||||||
useAddRelayMemberMutation,
|
useAddRelayMemberMutation,
|
||||||
useRelayMembersQuery,
|
useRelayMembersQuery,
|
||||||
} from "@/features/community-members/hooks";
|
} from "@/features/community-members/hooks";
|
||||||
import type { RelayMemberRole } from "@/shared/api/types";
|
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||||
import { cn } from "@/shared/lib/cn";
|
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 { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,15 +22,438 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/ui/dialog";
|
} from "@/shared/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import { Input } from "@/shared/ui/input";
|
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;
|
||||||
const ROLE_OPTIONS: Array<{ value: RelayMemberRole; label: string }> = [
|
label: string;
|
||||||
{ value: "member", label: "Member" },
|
}> = [
|
||||||
{ value: "admin", label: "Admin" },
|
{
|
||||||
|
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({
|
export function AddMemberDialog({
|
||||||
isOwner,
|
isOwner,
|
||||||
open,
|
open,
|
||||||
@@ -33,49 +463,8 @@ export function AddMemberDialog({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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 (
|
return (
|
||||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-md overflow-hidden p-0"
|
className="max-w-md overflow-hidden p-0"
|
||||||
data-testid="add-relay-member-dialog"
|
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">
|
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||||
<DialogTitle>Add member</DialogTitle>
|
<DialogTitle>Add member</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add a user to this relay by their public key.
|
Add a person to this community by their public key.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="px-6 py-4">
|
||||||
<form
|
<DirectAddMemberForm
|
||||||
className="contents"
|
isOwner={isOwner}
|
||||||
onSubmit={(e) => {
|
onAdded={() => onOpenChange(false)}
|
||||||
e.preventDefault();
|
/>
|
||||||
handleAdd();
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/ui/dialog";
|
} from "@/shared/ui/dialog";
|
||||||
|
import { DirectAddMemberForm } from "./AddMemberDialog";
|
||||||
import {
|
import {
|
||||||
DEFAULT_INVITE_TTL_SECS,
|
DEFAULT_INVITE_TTL_SECS,
|
||||||
InviteLinkSection,
|
InviteLinkSection,
|
||||||
} from "./InviteLinkSection";
|
} from "./InviteLinkSection";
|
||||||
|
|
||||||
export function CommunityInviteDialog({
|
export function CommunityInviteDialog({
|
||||||
|
isOwner,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
open,
|
open,
|
||||||
}: {
|
}: {
|
||||||
|
isOwner: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
open: boolean;
|
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);
|
const [ttlSecs, setTtlSecs] = React.useState(DEFAULT_INVITE_TTL_SECS);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -36,11 +37,24 @@ export function CommunityInviteDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Invite to community</DialogTitle>
|
<DialogTitle>Invite to community</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Anyone with this link can join this community.
|
Add someone directly or share a link they can use to join.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ export function CommunityMembersSettingsCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommunityInviteDialog
|
<CommunityInviteDialog
|
||||||
|
isOwner={currentRole === "owner"}
|
||||||
onOpenChange={setInviteDialogOpen}
|
onOpenChange={setInviteDialogOpen}
|
||||||
open={inviteDialogOpen}
|
open={inviteDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ export function InviteLinkSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="pt-2" data-testid="community-invite-link-section">
|
<section data-testid="community-invite-link-section">
|
||||||
<div className="space-y-5">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="text-sm font-medium">Expires after</span>
|
<span className="text-sm font-medium">Expires after</span>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function SelectedRecipientChip({
|
|||||||
user: UserSearchResult;
|
user: UserSearchResult;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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
|
<button
|
||||||
aria-label={`Remove ${label}`}
|
aria-label={`Remove ${label}`}
|
||||||
className={cn(
|
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
|
* 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
|
* the input is not a syntactically valid `nsec1…` (does NOT throw — this is
|
||||||
|
|||||||
50
desktop/src/shared/lib/parsePubkeyInput.test.mjs
Normal file
50
desktop/src/shared/lib/parsePubkeyInput.test.mjs
Normal file
@@ -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(
|
await expect(page.getByTestId("community-invite-email-field")).toHaveCount(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
await expect(page.getByTestId("member-pubkey-input")).toBeVisible();
|
||||||
await expect(page.getByTestId("copy-invite-link")).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 expect(page.getByTestId("settings-community-members")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("community-invite-dialog-trigger").click();
|
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-url")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0);
|
await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText(
|
await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText(
|
||||||
|
|||||||
@@ -5,11 +5,21 @@ import { installMockBridge } from "../helpers/bridge";
|
|||||||
import { openSettings } from "../helpers/settings";
|
import { openSettings } from "../helpers/settings";
|
||||||
|
|
||||||
const OUTDIR = "test-results/invites-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, {
|
await installMockBridge(page, {
|
||||||
relayRequiresMembership: true,
|
relayRequiresMembership: true,
|
||||||
relayRole: "owner",
|
relayRole: testInfo.title.includes("admin can add members")
|
||||||
|
? "admin"
|
||||||
|
: "owner",
|
||||||
});
|
});
|
||||||
await page.route("**/api/invites", async (route) => {
|
await page.route("**/api/invites", async (route) => {
|
||||||
await route.fulfill({
|
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.getByTestId("community-invite-email-field")).toHaveCount(0);
|
||||||
await expect(page.getByPlaceholder("Type an email address")).toHaveCount(0);
|
await expect(page.getByPlaceholder("Type an email address")).toHaveCount(0);
|
||||||
await expect(
|
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();
|
).toBeVisible();
|
||||||
await expect(dialog.getByText("Expires after")).toBeVisible();
|
await expect(
|
||||||
await expect(dialog.getByText("Limit number of uses")).toBeVisible();
|
dialog.getByRole("heading", { name: "Add someone", exact: true }),
|
||||||
await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText(
|
).toHaveCount(0);
|
||||||
"No limit",
|
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("copy-invite-link")).toHaveText("Copy link");
|
||||||
await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0);
|
await expect(page.getByTestId("invite-link-ttl-trigger")).toHaveText(
|
||||||
await expect(page.getByTestId("invite-link-url")).toHaveCount(0);
|
"3 days",
|
||||||
|
);
|
||||||
|
|
||||||
const expiryTrigger = page.getByTestId("invite-link-ttl-trigger");
|
await page.getByTestId("member-pubkey-input").fill(DIRECT_ADD_NPUB);
|
||||||
await expect(expiryTrigger).toHaveText("3 days");
|
await expect(page.getByTestId("member-search-popover")).toBeVisible();
|
||||||
await expect(expiryTrigger).toHaveCSS("font-size", "14px");
|
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(
|
await expect(
|
||||||
dialog.getByText("Limit number of uses", { exact: true }),
|
page.getByTestId(`member-search-selection-remove-${SECOND_DIRECT_ADD_HEX}`),
|
||||||
).toHaveCSS("font-size", "14px");
|
|
||||||
await expiryTrigger.click();
|
|
||||||
await expect(page.getByRole("menu")).not.toContainText("Expires after");
|
|
||||||
await expect(
|
|
||||||
page.getByRole("menuitemradio", { name: "1 day" }),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(memberSearch).toBeFocused();
|
||||||
|
await memberRole.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("menuitemradio", { name: "30 days" }),
|
page.getByRole("menuitemradio", { name: "Admin" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByTestId("confirm-add-member")).toBeEnabled();
|
||||||
await waitForAnimations(page);
|
await waitForAnimations(page);
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
await dialog.screenshot({ path: `${OUTDIR}/02-invite-dialog.png` });
|
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