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 ? (
<>
@@ -287,6 +297,7 @@ export function CommunityMembersSettingsCard({
? "This relay didn’t return its member list."
: null;
const membersQuery = useRelayMembersQuery(canManage);
+ const addedDatesQuery = useRelayMemberAddedDatesQuery(canManage);
const isRetrying = myMembershipQuery.isFetching || membersQuery.isFetching;
const members = React.useMemo(
() =>
@@ -453,6 +464,9 @@ export function CommunityMembersSettingsCard({
items={filteredMembers}
renderItem={(member) => (
{
assert.ok(invalidated(client, ["myRelayMembershipLookup"]));
});
+ it("refreshes member Added dates once per burst of rosters", () => {
+ client.setQueryData(["relayMemberAddedDates"], {});
+
+ relay.onEvent(event(0, NEW_MEMBER));
+ mock.timers.tick(windowMs);
+ assert.equal(invalidated(client, ["relayMemberAddedDates"]), false);
+
+ relay.onEvent(
+ snapshot(200, [
+ [VIEWER, "member"],
+ [NEW_MEMBER, "member"],
+ ]),
+ );
+ assert.equal(invalidated(client, ["relayMemberAddedDates"]), false);
+ mock.timers.tick(windowMs);
+ assert.ok(invalidated(client, ["relayMemberAddedDates"]));
+ });
+
it("ignores a roster older than the one already applied", () => {
relay.onEvent(
snapshot(200, [
diff --git a/desktop/src/features/profile/useLiveDirectoryUpdates.ts b/desktop/src/features/profile/useLiveDirectoryUpdates.ts
index 1dbb552b282..de54a3a0b60 100644
--- a/desktop/src/features/profile/useLiveDirectoryUpdates.ts
+++ b/desktop/src/features/profile/useLiveDirectoryUpdates.ts
@@ -8,6 +8,7 @@ import {
import {
myRelayMembershipLookupQueryKey,
myRelayMembershipQueryKey,
+ relayMemberAddedDatesQueryKey,
relayMembersQueryKey,
} from "@/features/community-members/hooks";
import { updateCachedChannelMemberDisplayNames } from "@/features/channels/channelMemberProfileCache";
@@ -85,6 +86,7 @@ export function startLiveDirectoryUpdates(
let timer: ReturnType | null = null;
let retryTimer: ReturnType | null = null;
let viewerRoleChanged = false;
+ let rosterChanged = false;
let newestMembershipAt = 0;
// pubkey → the roster name its latest kind:0 carries.
const changedProfiles = new Map();
@@ -128,6 +130,14 @@ export function startLiveDirectoryUpdates(
},
);
}
+ // A snapshot carries no Added dates. Refetched here, not per snapshot: at
+ // most one request per window, and only while the Members panel is open.
+ if (rosterChanged) {
+ rosterChanged = false;
+ void queryClient.invalidateQueries({
+ queryKey: relayMemberAddedDatesQueryKey,
+ });
+ }
if (viewerRoleChanged) {
viewerRoleChanged = false;
void queryClient.invalidateQueries({
@@ -148,6 +158,7 @@ export function startLiveDirectoryUpdates(
// Same parser `listRelayMembers` feeds the query with, so the roster is
// written directly instead of costing a request per snapshot.
queryClient.setQueryData(relayMembersQueryKey, roster);
+ rosterChanged = true;
// A join changes no one's directory entry (that waits for their kind:0),
// so only a change to the viewer's own role is worth a refetch.
const role = roster.find((m) => m.pubkey === viewer)?.role ?? null;
@@ -158,13 +169,12 @@ export function startLiveDirectoryUpdates(
)?.membership,
];
if (
- !shown.some(
+ shown.some(
(member) => member !== undefined && (member?.role ?? null) !== role,
)
) {
- return;
+ viewerRoleChanged = true;
}
- viewerRoleChanged = true;
} else if (event.kind === KIND_PROFILE) {
changedProfiles.set(
normalizePubkey(event.pubkey),
diff --git a/desktop/src/shared/api/relayMembers.test.mjs b/desktop/src/shared/api/relayMembers.test.mjs
index 871bec0cc14..e4903c6f6f2 100644
--- a/desktop/src/shared/api/relayMembers.test.mjs
+++ b/desktop/src/shared/api/relayMembers.test.mjs
@@ -3,8 +3,10 @@ import test from "node:test";
import {
canManageCommunityMembers,
+ listRelayMemberAddedDates,
loadRelayMembershipLookup,
relayMemberActions,
+ relayMembersFromEvent,
shouldShowCommunityMembersSettings,
shouldWarnMissingMembershipSnapshot,
} from "./relayMembers.ts";
@@ -184,3 +186,57 @@ test("row actions need a known owner or admin viewer", () => {
assert.deepEqual(relayMemberActions("owner", "owner", true), none);
assert.deepEqual(relayMemberActions("admin", "member", true), none);
});
+
+test("added dates come from each member's newest announcement", async () => {
+ const alice = "a".repeat(64);
+ const bob = "b".repeat(64);
+ const filters = [];
+ const dates = await listRelayMemberAddedDates(async (filter) => {
+ filters.push(filter);
+ return [
+ // Removed and re-added: the later add is the current membership.
+ { created_at: 3_000, tags: [["p", alice]] },
+ { created_at: 1_000, tags: [["p", alice]] },
+ { created_at: 2_000, tags: [["-"], ["p", bob.toUpperCase()]] },
+ { created_at: 4_000, tags: [["-"]] },
+ ];
+ });
+
+ assert.deepEqual(filters, [{ kinds: [8000], limit: 1_000 }]);
+ assert.deepEqual(dates, {
+ [alice]: new Date(3_000_000).toISOString(),
+ [bob]: new Date(2_000_000).toISOString(),
+ });
+});
+
+test("a full page of announcements warns that older dates are missing", async () => {
+ const warnings = [];
+ const realWarn = console.warn;
+ console.warn = (message) => warnings.push(message);
+ try {
+ await listRelayMemberAddedDates(async () =>
+ Array.from({ length: 999 }, () => ({ created_at: 1, tags: [] })),
+ );
+ assert.equal(warnings.length, 0);
+
+ await listRelayMemberAddedDates(async () =>
+ Array.from({ length: 1_000 }, () => ({ created_at: 1, tags: [] })),
+ );
+ assert.match(warnings[0], /older members show no Added date/);
+ } finally {
+ console.warn = realWarn;
+ }
+});
+
+test("the roster carries no date, since the snapshot's time isn't anyone's", () => {
+ const [member] = relayMembersFromEvent({
+ created_at: 5_000,
+ tags: [["member", "a".repeat(64), "member"]],
+ });
+
+ assert.deepEqual(member, {
+ pubkey: "a".repeat(64),
+ role: "member",
+ addedBy: null,
+ });
+});
diff --git a/desktop/src/shared/api/relayMembers.ts b/desktop/src/shared/api/relayMembers.ts
index 80dbe50464e..e4fa0c7fbf4 100644
--- a/desktop/src/shared/api/relayMembers.ts
+++ b/desktop/src/shared/api/relayMembers.ts
@@ -1,4 +1,5 @@
import { relayClient } from "@/shared/api/relayClient";
+import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
import { invokeTauri, signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import type {
@@ -8,6 +9,8 @@ import type {
} from "@/shared/api/types";
const KIND_NIP43_MEMBERSHIP_LIST = 13534;
+const KIND_NIP43_MEMBER_ADDED = 8000;
+const MEMBER_ADDED_LIMIT = 1_000;
const KIND_RELAY_ADMIN_ADD_MEMBER = 9030;
const KIND_RELAY_ADMIN_REMOVE_MEMBER = 9031;
const KIND_RELAY_ADMIN_CHANGE_ROLE = 9032;
@@ -90,7 +93,6 @@ export function shouldShowCommunityMembersSettings(
export function relayMembersFromEvent(event: RelayEvent): RelayMember[] {
const seen = new Set();
const members: RelayMember[] = [];
- const createdAt = eventCreatedAtIso(event);
for (const tag of event.tags) {
const [name, rawPubkey, maybeRoleOrRelay, maybePTagRole] = tag;
@@ -108,7 +110,6 @@ export function relayMembersFromEvent(event: RelayEvent): RelayMember[] {
pubkey,
role,
addedBy: null,
- createdAt,
});
}
@@ -163,6 +164,43 @@ export async function listRelayMembers(): Promise {
return event ? relayMembersFromEvent(event) : [];
}
+/**
+ * When each member was added (ISO time by hex pubkey), from the relay's
+ * kind:8000 announcements.
+ *
+ * The kind:13534 snapshot can't say: it is one replaceable event whose
+ * `created_at` moves on every roster change, and its tags hold no per-member
+ * time. Someone removed and re-added has several announcements, and the newest
+ * is their current membership. A member the relay never announced (the
+ * bootstrap owner, an allowlist backfill) gets no entry, so their row shows no
+ * date rather than a wrong one.
+ */
+export async function listRelayMemberAddedDates(
+ fetchEvents: (filter: RelaySubscriptionFilter) => Promise = (
+ filter,
+ ) => relayClient.fetchEvents(filter),
+): Promise> {
+ const events = await fetchEvents({
+ kinds: [KIND_NIP43_MEMBER_ADDED],
+ limit: MEMBER_ADDED_LIMIT,
+ });
+ // ponytail: one page, the relay's max. Past 1,000 adds the oldest members
+ // lose their date; page with until + before_id (fetchHuddleLifecycleHistory)
+ // if a community gets there.
+ if (events.length >= MEMBER_ADDED_LIMIT) {
+ console.warn(
+ `Read only the newest ${MEMBER_ADDED_LIMIT} member additions: older members show no Added date.`,
+ );
+ }
+
+ const addedAt: Record = {};
+ for (const event of [...events].sort((a, b) => a.created_at - b.created_at)) {
+ const pubkey = event.tags.find(([name]) => name === "p")?.[1];
+ if (pubkey) addedAt[normalizePubkey(pubkey)] = eventCreatedAtIso(event);
+ }
+ return addedAt;
+}
+
export async function relayRequiresMembership(
relayUrl?: string,
): Promise {
diff --git a/desktop/src/shared/api/relayQueryInvalidation.test.mjs b/desktop/src/shared/api/relayQueryInvalidation.test.mjs
index 134f7204303..8fa6de6b0b9 100644
--- a/desktop/src/shared/api/relayQueryInvalidation.test.mjs
+++ b/desktop/src/shared/api/relayQueryInvalidation.test.mjs
@@ -49,6 +49,7 @@ test("relay invalidation includes relay-backed channel and profile queries", ()
["user-status", "alice"],
["relay-agents"],
["relayMembers"],
+ ["relayMemberAddedDates"],
["myRelayMembershipLookup"],
["archivedIdentities"],
["oaOwner", "alice"],
diff --git a/desktop/src/shared/api/relayQueryInvalidation.ts b/desktop/src/shared/api/relayQueryInvalidation.ts
index 852766d1af5..c63e9dc640b 100644
--- a/desktop/src/shared/api/relayQueryInvalidation.ts
+++ b/desktop/src/shared/api/relayQueryInvalidation.ts
@@ -24,6 +24,7 @@ const RELAY_QUERY_ROOTS = new Set([
"pulse-reactions",
"pulse-timeline",
"relay-agents",
+ "relayMemberAddedDates",
"relayMembers",
"reminders",
"run-approvals",
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index f24e86b0a6a..28217dcb655 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -702,7 +702,6 @@ function fromRawRelayMember(raw: RawRelayMember): RelayMember {
pubkey: raw.pubkey,
role: raw.role as RelayMemberRole,
addedBy: raw.added_by,
- createdAt: raw.created_at,
};
}
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index b03d4bfe016..8f585f73804 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -253,7 +253,6 @@ export type RelayMember = {
pubkey: string;
role: RelayMemberRole;
addedBy: string | null;
- createdAt: string;
};
export type RelayAgent = {
pubkey: string;
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 48c44ecd984..4889c68900c 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -532,6 +532,8 @@ type E2eConfig = {
relayMembershipEoseDelayMs?: number;
/** False: answer kind:13534 REQs with EOSE only, as a relay with no snapshot does. */
relayMembershipSnapshot?: boolean;
+ /** More community members, on the roster with no profile. */
+ extraRelayMemberPubkeys?: string[];
relayRole?: "owner" | "admin" | "member" | null;
// Descriptors returned by the mocked `pick_and_upload_media` /
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
@@ -2086,7 +2088,8 @@ function resetMockRelayMembers(config: E2eConfig | undefined) {
pubkey: ALICE_PUBKEY,
role: "admin",
added_by: pubkey,
- created_at: isoMinutesAgo(90),
+ // Fixed and old, so a date taken from the snapshot (always now) can't pass.
+ created_at: "2026-03-14T12:00:00.000Z",
},
{
pubkey: BOB_PUBKEY,
@@ -2094,6 +2097,12 @@ function resetMockRelayMembers(config: E2eConfig | undefined) {
added_by: pubkey,
created_at: isoMinutesAgo(60),
},
+ ...(config?.mock?.extraRelayMemberPubkeys ?? []).map((extraPubkey) => ({
+ pubkey: extraPubkey,
+ role: "member" as const,
+ added_by: pubkey,
+ created_at: isoMinutesAgo(30),
+ })),
];
}
@@ -11054,6 +11063,26 @@ function sendToMockSocket(args: {
}
const filter = rest[1] as MockFilter;
+ if (filter.kinds?.includes(8000)) {
+ // As on a real relay, the bootstrap owner (added_by null) is never announced.
+ for (const member of mockRelayMembers) {
+ if (member.added_by === null) continue;
+ sendWsText(socket.handler, [
+ "EVENT",
+ subId,
+ createMockEvent(
+ 8000,
+ "",
+ [["p", member.pubkey]],
+ "f".repeat(64),
+ Math.floor(Date.parse(member.created_at) / 1000),
+ ),
+ ]);
+ }
+ sendWsText(socket.handler, ["EOSE", subId]);
+ return;
+ }
+
if (filter.kinds?.includes(13534)) {
if (getConfig()?.mock?.relayMembershipSnapshot === false) {
sendWsText(socket.handler, ["EOSE", subId]);
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index 407a7b99b4c..c193b09e97e 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
+import { nip19 } from "nostr-tools";
import { GENERAL_ALICE_EVENT_ID } from "@/testing/e2eSeedIds";
import {
@@ -26,6 +27,10 @@ const CACHED_PROFILE_LABELS_TAG = "@cached-profile-labels";
// view-activity gate (memberIsBot && viewerIsOwner) opens for it.
const OWNED_RELAY_AGENT_PUBKEY =
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
+const PASTED_HEX =
+ "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
+const PASTED_NPUB =
+ "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";
const DM_RELAY_AGENT_PUBKEY =
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
// Unnamed roster fixtures whose two plausible orders disagree (the e2e twin
@@ -4644,6 +4649,144 @@ test("members sidebar can invite and remove managed agents", async ({
await expectMembersTriggerCount(page, initialMemberCount);
});
+test("members sidebar adds someone with no profile from a pasted npub", async ({
+ page,
+}) => {
+ await installMockBridge(page, { extraRelayMemberPubkeys: [PASTED_HEX] });
+ await page.goto("/");
+ await openMembersSidebar(page, "general");
+ const initialMemberCount = await readMembersTriggerCount(page);
+
+ await page.getByTestId("channel-management-search-users").fill(PASTED_NPUB);
+ const result = page.getByTestId(`channel-user-search-result-${PASTED_HEX}`);
+ await expect(result).toContainText("npub1a2d…yp60");
+ await result.getByRole("button", { name: "Add", exact: true }).click();
+
+ // Same paste now finds them as a member, with nothing left to add.
+ await expect(page.getByTestId(`sidebar-member-${PASTED_HEX}`)).toBeVisible();
+ await expect(result).toHaveCount(0);
+ await expectMembersTriggerCount(page, initialMemberCount + 1);
+});
+
+test("members sidebar takes a pasted hex key, and Enter adds it", async ({
+ page,
+}) => {
+ await installMockBridge(page, { extraRelayMemberPubkeys: [PASTED_HEX] });
+ await page.goto("/");
+ await openMembersSidebar(page, "general");
+
+ const search = page.getByTestId("channel-management-search-users");
+ await search.fill(PASTED_HEX.toUpperCase());
+ await expect(
+ page.getByTestId(`channel-user-search-result-${PASTED_HEX}`),
+ ).toBeVisible();
+ await search.press("Enter");
+
+ await expect(page.getByTestId(`sidebar-member-${PASTED_HEX}`)).toBeVisible();
+});
+
+test("members sidebar says why a pasted key outside the community can't be added", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await openMembersSidebar(page, "general");
+
+ await page.getByTestId("channel-management-search-users").fill(PASTED_NPUB);
+
+ await expect(
+ page.getByText("That key isn’t a member of this community yet."),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId(`channel-user-search-result-${PASTED_HEX}`),
+ ).toHaveCount(0);
+});
+
+test("members sidebar offers a pasted key when the relay has no member list", async ({
+ page,
+}) => {
+ await installMockBridge(page, { relayMembershipSnapshot: false });
+ await page.goto("/");
+ await openMembersSidebar(page, "general");
+
+ await page.getByTestId("channel-management-search-users").fill(PASTED_NPUB);
+
+ await expect(
+ page.getByTestId(`channel-user-search-result-${PASTED_HEX}`),
+ ).toBeVisible();
+});
+
+test("members sidebar names a pasted key that has a profile", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ searchProfiles: [{ pubkey: PASTED_HEX, displayName: "Priya" }],
+ });
+ await page.goto("/");
+ await openMembersSidebar(page, "general");
+
+ await page.getByTestId("channel-management-search-users").fill(PASTED_NPUB);
+
+ await expect(
+ page.getByTestId(`channel-user-search-result-${PASTED_HEX}`),
+ ).toContainText("Priya");
+});
+
+test("members sidebar keeps a pasted agent key under the agent rules", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ searchProfiles: [
+ {
+ pubkey: PASTED_HEX,
+ displayName: "Scout",
+ ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
+ isAgent: true,
+ },
+ ],
+ });
+ await page.goto("/");
+ await openMembersSidebar(page, "general");
+
+ await page.getByTestId("channel-management-search-users").fill(PASTED_NPUB);
+
+ // Someone else's agent, not shared with this viewer: pasting its key is no way around that.
+ await expect(page.getByText("No matching people or agents.")).toBeVisible();
+ await expect(
+ page.getByTestId(`channel-user-search-result-${PASTED_HEX}`),
+ ).toHaveCount(0);
+});
+
+test("members sidebar finds a current member by pasted npub without offering to add", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await openMembersSidebar(page, "general");
+
+ await page
+ .getByTestId("channel-management-search-users")
+ .fill(nip19.npubEncode(TEST_IDENTITIES.alice.pubkey));
+
+ await expect(
+ page.getByTestId(`sidebar-member-${TEST_IDENTITIES.alice.pubkey}`),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId(
+ `channel-user-search-result-${TEST_IDENTITIES.alice.pubkey}`,
+ ),
+ ).toHaveCount(0);
+});
+
+test("members sidebar offers nothing for a malformed key", async ({ page }) => {
+ await page.goto("/");
+ await openMembersSidebar(page, "general");
+
+ await page
+ .getByTestId("channel-management-search-users")
+ .fill(`${PASTED_NPUB.slice(0, -1)}q`);
+
+ await expect(page.getByText("No matching people or agents.")).toBeVisible();
+});
+
test("members sidebar pages add-member search beyond the first 50 people", async ({
page,
}) => {
@@ -4802,7 +4945,10 @@ test("members modal does not show direct pubkey entry", async ({ page }) => {
).toHaveCount(0);
await expect(
page.getByTestId("channel-management-search-users"),
- ).toHaveAttribute("placeholder", "Add people and agents");
+ ).toHaveAttribute(
+ "placeholder",
+ "Add people and agents, or paste a public key",
+ );
});
test("channel header omits the add agent action", async ({ page }) => {
@@ -4960,7 +5106,10 @@ test("private-channel members can add people and managed agents without admin",
await expect(page.getByTestId("members-sidebar-add-denied")).toHaveCount(0);
await expect(
page.getByTestId("channel-management-search-users"),
- ).toHaveAttribute("placeholder", "Add people and agents");
+ ).toHaveAttribute(
+ "placeholder",
+ "Add people and agents, or paste a public key",
+ );
await page.getByTestId("channel-management-search-users").fill("char");
await page
diff --git a/desktop/tests/e2e/invites-settings-screenshots.spec.ts b/desktop/tests/e2e/invites-settings-screenshots.spec.ts
index 79f6ccb2c5d..679aad7f7ca 100644
--- a/desktop/tests/e2e/invites-settings-screenshots.spec.ts
+++ b/desktop/tests/e2e/invites-settings-screenshots.spec.ts
@@ -249,11 +249,31 @@ test("owner adds a member by npub from Add member", async ({ page }) => {
await dialog.getByTestId("confirm-add-member").click();
await expect(dialog).toHaveCount(0);
+ const row = page.getByTestId(`relay-member-row-${DIRECT_ADD_HEX}`);
+ await expect(row).toBeVisible();
+ // No profile: the row names them by short npub, so two can be told apart.
await expect(
- page.getByTestId(`relay-member-row-${DIRECT_ADD_HEX}`),
+ page.getByTestId(`relay-member-name-${DIRECT_ADD_HEX}`),
+ ).toHaveText("npub1a2d…yp60");
+ await expect(
+ page.getByRole("button", { name: "Actions for npub1a2d…yp60" }),
).toBeVisible();
});
+test("member rows show when each was added, not when the list last changed", async ({
+ page,
+}) => {
+ await expect(
+ page.getByTestId(`relay-member-row-${TEST_IDENTITIES.alice.pubkey}`),
+ ).toContainText("Added Mar 14, 2026");
+ // The relay never announced the bootstrap owner, so there is no date to show.
+ await expect(
+ page
+ .locator('[data-testid^="relay-member-row-"]')
+ .filter({ hasText: "You" }),
+ ).not.toContainText("Added");
+});
+
test("owner removes a member via the ... menu with confirm", async ({
page,
}) => {
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 02955b6dffa..abe1d4b23b0 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -427,6 +427,8 @@ type MockBridgeOptions = {
relayMembershipEoseDelayMs?: number;
/** False: answer kind:13534 REQs with EOSE only, as a relay with no snapshot does. */
relayMembershipSnapshot?: boolean;
+ /** More community members, on the roster with no profile. */
+ extraRelayMemberPubkeys?: string[];
talosPubkey?: string;
talkToTalosError?: string;
/**