diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 2a125e0d014..9a4e56b363f 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -31,7 +31,11 @@ import { useUsersBatchQuery, } from "@/features/profile/hooks"; import { formatOwnerLabel } from "@/features/profile/lib/identity"; -import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; +import { + getKeyboardSearchSelection, + rankUserCandidatesBySearch, +} from "@/features/profile/lib/userCandidateSearch"; +import { useRelayMembersQuery } from "@/features/community-members/hooks"; import { useAgentAvailabilityLookup } from "@/features/agents/lib/useAgentAvailability"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -52,6 +56,7 @@ import { } from "@/shared/ui/dialog"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; +import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { MODAL_SEARCH_INPUT_CLASS, @@ -202,9 +207,14 @@ export function MembersSidebar({ const memberProfilesQuery = useUsersBatchQuery(allMemberPubkeys, { enabled: open && rawMembers.length > 0, }); - const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + // A pasted npub or hex key searches as its hex form, which matches existing + // members and ranks the pasted candidate below. + const normalizedSearchQuery = + parsePubkeyInput(searchQuery) ?? searchQuery.trim().toLowerCase(); const deferredSearchQuery = React.useDeferredValue(searchQuery.trim()); - const normalizedDeferredSearchQuery = deferredSearchQuery.toLowerCase(); + const pastedPubkey = parsePubkeyInput(deferredSearchQuery); + const normalizedDeferredSearchQuery = + pastedPubkey ?? deferredSearchQuery.toLowerCase(); const filteredActiveMembers = React.useMemo(() => { if (!normalizedSearchQuery) { return activeMembers; @@ -252,13 +262,43 @@ export function MembersSidebar({ channel?.visibility !== "open"; const userSearchQuery = useInfiniteUserSearchQuery(deferredSearchQuery, { allowEmpty: false, + // User search matches profile text, never keys. A pasted key is looked up + // directly instead, so it needs no search round trip. enabled: open && canAddMembers && + pastedPubkey === null && deferredSearchQuery.length >= MEMBER_SEARCH_MIN_QUERY_LENGTH, limit: MEMBER_ADD_RESULT_LIMIT, }); const userSearchResults = useFlattenedUserSearchResults(userSearchQuery.data); + const pastedProfileQuery = useUsersBatchQuery( + pastedPubkey ? [pastedPubkey] : [], + { enabled: open && canAddMembers }, + ); + const rosterQuery = useRelayMembersQuery( + open && canAddMembers && pastedPubkey !== null, + ); + const pastedProfile = pastedPubkey + ? pastedProfileQuery.data?.profiles[pastedPubkey] + : undefined; + const pastedKeyResolved = + pastedProfileQuery.isSuccess && + !pastedProfileQuery.isFetching && + !rosterQuery.isLoading; + // The relay checks channel policy only, so it would seat any key: a typo, an + // event id, someone not let into the community. An empty roster means no + // member list to check against, as on an open relay. + const pastedKeyIsOutsider = + pastedKeyResolved && + !pastedProfile && + (rosterQuery.data?.length ?? 0) > 0 && + !rosterQuery.data?.some((member) => member.pubkey === pastedPubkey); + const pastedKeyNotice = pastedKeyIsOutsider + ? "That key isn’t a member of this community yet. An owner or admin can add them in Settings > Members." + : pastedPubkey && pastedProfileQuery.isError + ? "Couldn’t look up that key. Check your connection and try again." + : null; const isArchivedDiscovery = useIsArchivedPredicate(); const addSearchResults = React.useMemo(() => { if (!canAddMembers || normalizedDeferredSearchQuery.length === 0) { @@ -348,6 +388,20 @@ export function MembersSidebar({ }); } + // Someone with no kind:0 profile is invisible to user search, so a pasted + // key is the only way to reach them. Waits for the lookup so an agent's key + // is treated as an agent, not added as a person. + if (pastedPubkey && pastedKeyResolved && !pastedKeyIsOutsider) { + addCandidate({ + pubkey: pastedPubkey, + displayName: pastedProfile?.displayName ?? null, + avatarUrl: pastedProfile?.avatarUrl ?? null, + nip05Handle: pastedProfile?.nip05Handle ?? null, + ownerPubkey: pastedProfile?.ownerPubkey ?? null, + isAgent: pastedProfile?.isAgent === true, + }); + } + const coalescedCandidates = coalesceAgentAutocompleteCandidates( [...candidatesByPubkey.values()], { @@ -371,11 +425,17 @@ export function MembersSidebar({ managedAgentsQuery.data, memberPubkeys, normalizedDeferredSearchQuery, + pastedKeyIsOutsider, + pastedKeyResolved, + pastedProfile, + pastedPubkey, relayAgentsQuery.data, userSearchResults, ]); const isAddSearchLoading = userSearchQuery.isLoading || + pastedProfileQuery.isFetching || + rosterQuery.isLoading || managedAgentsQuery.isLoading || relayAgentsQuery.isLoading || channelsQuery.isLoading; @@ -727,16 +787,23 @@ export function MembersSidebar({ setSearchQuery(event.target.value); }} onKeyDown={(event) => { - if (event.key !== "Enter" || addSearchResults.length === 0) { + // Null until the results match what's in the box, so Enter + // right after a paste can't add the previous search's top row. + const selection = getKeyboardSearchSelection({ + currentQuery: normalizedSearchQuery, + rankedQuery: normalizedDeferredSearchQuery, + results: addSearchResults, + }); + if (event.key !== "Enter" || !selection) { return; } event.preventDefault(); - void handleAddSearchResult(addSearchResults[0]); + void handleAddSearchResult(selection); }} placeholder={ canAddMembers - ? "Add people and agents" + ? "Add people and agents, or paste a public key" : "Search people and agents" } ref={searchInputRef} @@ -807,7 +874,7 @@ export function MembersSidebar({ addSearchResults.length === 0 && !isAddSearchLoading ? (

- No matching people or agents. + {pastedKeyNotice ?? "No matching people or agents."}

) : null} diff --git a/desktop/src/features/community-members/hooks.ts b/desktop/src/features/community-members/hooks.ts index 2f95c1aa9fd..384df37b4e5 100644 --- a/desktop/src/features/community-members/hooks.ts +++ b/desktop/src/features/community-members/hooks.ts @@ -5,12 +5,15 @@ import { changeRelayMemberRole, getMyRelayMembership, getMyRelayMembershipLookup, + listRelayMemberAddedDates, listRelayMembers, removeRelayMember, } from "@/shared/api/relayMembers"; import type { RelayMember } from "@/shared/api/types"; export const relayMembersQueryKey = ["relayMembers"] as const; +// Kept apart from relayMembersQueryKey: live snapshots overwrite that cache. +export const relayMemberAddedDatesQueryKey = ["relayMemberAddedDates"] as const; export const myRelayMembershipQueryKey = ["myRelayMembership"] as const; export const myRelayMembershipLookupQueryKey = [ "myRelayMembershipLookup", @@ -25,6 +28,15 @@ export function useRelayMembersQuery(enabled = true) { }); } +export function useRelayMemberAddedDatesQuery(enabled = true) { + return useQuery({ + enabled, + queryKey: relayMemberAddedDatesQueryKey, + queryFn: () => listRelayMemberAddedDates(), + staleTime: 30_000, + }); +} + export function useMyRelayMembershipQuery() { return useQuery({ queryKey: myRelayMembershipQueryKey, @@ -59,7 +71,6 @@ export function useAddRelayMemberMutation() { pubkey, role: role as RelayMember["role"], addedBy: null, - createdAt: new Date().toISOString(), }, ]); diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index c2259e3ac97..b104b6b9188 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -14,6 +14,7 @@ import { toast } from "sonner"; import { useChangeRelayMemberRoleMutation, useMyRelayMembershipLookupQuery, + useRelayMemberAddedDatesQuery, useRelayMembersQuery, } from "@/features/community-members/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; @@ -64,7 +65,9 @@ function formatDisplayName(member: RelayMember, displayName?: string | null) { ) { return trimmedDisplayName; } - return member.role === "owner" ? "Community owner" : "Unnamed member"; + return member.role === "owner" + ? "Community owner" + : truncateNpub(member.pubkey); } function npubFromPubkey(pubkey: string): string | null { @@ -110,12 +113,15 @@ function HoverMemberIdentity({ } function RelayMemberRow({ + addedAt, currentRole, currentPubkey, onRemove, profile, member, }: { + /** Undefined when the relay holds no announcement of this member being added. */ + addedAt?: string; /** Null when the role is unknown: the row then offers no actions. */ currentRole: RelayMemberRole | null; currentPubkey?: string; @@ -187,10 +193,14 @@ function RelayMemberRow({ data-settings-subcopy > {member.role} - - Added {formatDate(member.createdAt)} + {addedAt ? ( + <> + + Added {formatDate(addedAt)} + + ) : null} {isSelf ? ( <>