Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 74 additions & 7 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()],
{
Expand All @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -807,7 +874,7 @@ export function MembersSidebar({
addSearchResults.length === 0 &&
!isAddSearchLoading ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
No matching people or agents.
{pastedKeyNotice ?? "No matching people or agents."}
</p>
) : null}
</div>
Expand Down
13 changes: 12 additions & 1 deletion desktop/src/features/community-members/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -59,7 +71,6 @@ export function useAddRelayMemberMutation() {
pubkey,
role: role as RelayMember["role"],
addedBy: null,
createdAt: new Date().toISOString(),
},
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { toast } from "sonner";
import {
useChangeRelayMemberRoleMutation,
useMyRelayMembershipLookupQuery,
useRelayMemberAddedDatesQuery,
useRelayMembersQuery,
} from "@/features/community-members/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -187,10 +193,14 @@ function RelayMemberRow({
data-settings-subcopy
>
<span className="shrink-0 capitalize">{member.role}</span>
<span aria-hidden="true" className="shrink-0">
·
</span>
<span className="shrink-0">Added {formatDate(member.createdAt)}</span>
{addedAt ? (
<>
<span aria-hidden="true" className="shrink-0">
·
</span>
<span className="shrink-0">Added {formatDate(addedAt)}</span>
</>
) : null}
{isSelf ? (
<>
<span aria-hidden="true" className="shrink-0">
Expand Down Expand Up @@ -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(
() =>
Expand Down Expand Up @@ -453,6 +464,9 @@ export function CommunityMembersSettingsCard({
items={filteredMembers}
renderItem={(member) => (
<RelayMemberRow
addedAt={
addedDatesQuery.data?.[normalizePubkey(member.pubkey)]
}
currentPubkey={currentPubkey}
currentRole={currentRole}
member={member}
Expand Down
18 changes: 18 additions & 0 deletions desktop/src/features/profile/useLiveDirectoryUpdates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,24 @@ describe("startLiveDirectoryUpdates", () => {
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, [
Expand Down
16 changes: 13 additions & 3 deletions desktop/src/features/profile/useLiveDirectoryUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import {
myRelayMembershipLookupQueryKey,
myRelayMembershipQueryKey,
relayMemberAddedDatesQueryKey,
relayMembersQueryKey,
} from "@/features/community-members/hooks";
import { updateCachedChannelMemberDisplayNames } from "@/features/channels/channelMemberProfileCache";
Expand Down Expand Up @@ -85,6 +86,7 @@ export function startLiveDirectoryUpdates(
let timer: ReturnType<typeof setTimeout> | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let viewerRoleChanged = false;
let rosterChanged = false;
let newestMembershipAt = 0;
// pubkey → the roster name its latest kind:0 carries.
const changedProfiles = new Map<string, string | null>();
Expand Down Expand Up @@ -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({
Expand All @@ -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<RelayMember[]>(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;
Expand All @@ -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),
Expand Down
Loading
Loading