diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6f86fb9e2ee..7af2dead091 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -5498,7 +5498,7 @@ dependencies = [ "rand 0.10.2", "regex-lite", "reqwest 0.13.4", - "rmcp 3.2.0", + "rmcp 3.9.0", "rpassword", "rustls", "same-file", @@ -5610,7 +5610,7 @@ dependencies = [ "prost 0.14.4", "prost-build 0.14.4", "protoc-bin-vendored", - "rmcp 3.2.0", + "rmcp 3.9.0", "schemars 1.2.1", "serde", "serde_json", @@ -8003,6 +8003,20 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "process-wrap" +version = "10.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f21b97672d2dc848e7b25701ab4535618b92f4861c13cc3f7f7bed52ad3c8da" +dependencies = [ + "futures", + "indexmap 2.14.0", + "nix 0.31.3", + "tokio", + "tracing", + "windows 0.62.2", +] + [[package]] name = "proptest" version = "1.11.0" @@ -8839,7 +8853,7 @@ dependencies = [ "chrono", "futures", "pin-project-lite", - "process-wrap", + "process-wrap 9.1.0", "serde", "serde_json", "thiserror 2.0.18", @@ -8851,9 +8865,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.2.0" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5" +checksum = "d24a7e92f4bcaa36b97fa93c4fa7ca8f3e669eced949345b3462dba333c7f909" dependencies = [ "async-trait", "base64 0.23.1", @@ -8864,15 +8878,17 @@ dependencies = [ "http-body", "http-body-util", "indexmap 2.14.0", + "libc", "pastey", "pin-project-lite", - "process-wrap", + "process-wrap 10.0.1", "rand 0.10.2", "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", "serde_json", + "sha2 0.11.0", "sse-stream", "thiserror 2.0.18", "tokio", @@ -8885,9 +8901,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.2.0" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda" +checksum = "b3140f24a766f80f9ec1607bde85680959a69b83d46fa9ce87dadab7e67ab029" dependencies = [ "darling 0.24.1", "proc-macro2", 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 ? ( <>