From 310df2ec33fbb075edf226ba18bf9a96d90ba81b Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 08:04:49 -0600 Subject: [PATCH] desktop: restore direct community member adds (#3634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) |
Before: Invites page entry point ![Before: Invites settings page](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3634/before-invites-page.png)
## 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 Signed-off-by: Wes Signed-off-by: kenny lopez Co-authored-by: Joah Gerstenberg Co-authored-by: Amp Co-authored-by: Carl Co-authored-by: kenny lopez --- .../channels/ui/ChannelMemberInviteCard.tsx | 65 +- .../community-members/ui/AddMemberDialog.tsx | 588 +++++++++++++----- .../ui/CommunityInviteDialog.tsx | 22 +- .../ui/CommunityMembersSettingsCard.tsx | 1 + .../ui/InviteLinkSection.tsx | 4 +- .../profile/ui/SelectedRecipientChip.tsx | 2 +- desktop/src/shared/lib/nostrUtils.ts | 28 + .../src/shared/lib/parsePubkeyInput.test.mjs | 50 ++ desktop/tests/e2e/community-rail.spec.ts | 1 + desktop/tests/e2e/invite-link-copy.spec.ts | 3 + .../e2e/invites-settings-screenshots.spec.ts | 152 ++++- 11 files changed, 742 insertions(+), 174 deletions(-) create mode 100644 desktop/src/shared/lib/parsePubkeyInput.test.mjs diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index 63ab164ea..dd370e8c6 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -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(() => { + 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} /> @@ -217,12 +247,41 @@ export function ChannelMemberInviteCard({ ) : null} {deferredInviteQuery.length > 0 ? (
- {userSearchQuery.isLoading ? ( + {userSearchQuery.isLoading && !directInvitee ? (

Searching…

- ) : inviteSearchResults.length > 0 ? ( + ) : inviteSearchResults.length > 0 || directInvitee ? (
+ {directInvitee ? ( + + ) : null} {inviteSearchResults.map((result) => ( + + event.preventDefault()} + sideOffset={4} + style={{ minWidth: "13rem" }} + > + + setRole(value as RelayMemberRole) + } + value={role} + > + {roleOptions.map((option) => ( + + {option.label} + + ))} + + + + + ) : null} + +
+
+ + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + sideOffset={6} + > +
+ {userSearchQuery.isLoading ? ( +

+ Searching… +

+ ) : searchResults.length > 0 || directResult ? ( + <> + {directResult ? ( + selectUser(directResult)} + user={directResult} + /> + ) : null} + {searchResults.map((user) => ( + selectUser(user)} + user={user} + /> + ))} + + ) : ( +

+ No people found. Paste a full npub or hex public key to add + someone directly. +

+ )} +
+
+ + + {selectedUsers.length > 0 ? ( + + + + ) : null} + + + {isAlreadyMember ? ( +

+ This person is already a community member. +

+ ) : null} + {userSearchQuery.error instanceof Error ? ( +

+ {userSearchQuery.error.message} +

+ ) : null} + + + {addMutation.error instanceof Error ? ( +

+ {addMutation.error.message} +

+ ) : null} + + ); +} + +function SearchResult({ + onSelect, + user, +}: { + onSelect: () => void; + user: UserSearchResult; +}) { + const name = formatSearchUserName(user); + const isDirectPubkey = user.displayName === null && user.nip05Handle === null; + + return ( + + ); +} + 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("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 ( - + Add member - Add a user to this relay by their public key. + Add a person to this community by their public key. - -
{ - e.preventDefault(); - handleAdd(); - }} - > -
-
-
- - setPubkey(e.target.value)} - placeholder="64-character hex pubkey" - spellCheck={false} - value={pubkey} - /> - {pubkey.trim().length > 0 && !isValidPubkey ? ( -

- Must be exactly 64 lowercase hex characters. -

- ) : null} - {isAlreadyMember ? ( -

- This pubkey is already a relay member. -

- ) : null} -
- -
-

Role

-
- {ROLE_OPTIONS.filter( - (opt) => isOwner || opt.value === "member", - ).map((opt) => ( - - ))} -
-
- - {addMutation.error instanceof Error ? ( -

- {addMutation.error.message} -

- ) : null} -
-
- -
- - -
-
+
+ onOpenChange(false)} + /> +
diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx index 9daca590f..c5cabc26a 100644 --- a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx +++ b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx @@ -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({ Invite to community - Anyone with this link can join this community. + Add someone directly or share a link they can use to join. - +
+ +
+ +
+

+ Link settings +

+ +
); diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 9097f3538..9a2417131 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -370,6 +370,7 @@ export function CommunityMembersSettingsCard({ diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index dc0735c85..05b468728 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -84,8 +84,8 @@ export function InviteLinkSection({ } return ( -
-
+
+
Expires after diff --git a/desktop/src/features/profile/ui/SelectedRecipientChip.tsx b/desktop/src/features/profile/ui/SelectedRecipientChip.tsx index 18bf6d3b8..bfc2f4316 100644 --- a/desktop/src/features/profile/ui/SelectedRecipientChip.tsx +++ b/desktop/src/features/profile/ui/SelectedRecipientChip.tsx @@ -46,7 +46,7 @@ export function SelectedRecipientChip({ user: UserSearchResult; }) { return ( -
+