fix(nip-29): fetch admin and member lists in parallel and correctly tag admins (#169)

* fix(nip-29): fetch admin and member lists in parallel and correctly tag admins

This commit improves NIP-29 group member fetching with two key changes:

1. **Parallel fetching**: Admins (kind 39001) and members (kind 39002) are now
   fetched in parallel using separate subscriptions, improving performance

2. **Correct admin role tagging**: Users in kind 39001 events now correctly
   default to "admin" role instead of "member" when no explicit role tag is
   provided, as per NIP-29 spec

Changes:
- Split participantsFilter into separate adminsFilter and membersFilter
- Use Promise.all to fetch both kinds in parallel
- Updated normalizeRole helper to accept a defaultRole parameter
- Process kind 39001 with "admin" default, kind 39002 with "member" default
- Added clearer logging for admin/member event counts

Related: src/lib/chat/adapters/nip-29-adapter.ts:192-320

* refactor(nip-29): simplify parallel fetch using pool.request

Use pool.request() with both filters in a single call instead of manual
subscription management. This is cleaner and more idiomatic:
- Auto-closes on EOSE (no manual unsubscribe needed)
- Fetches both kinds (39001 and 39002) in parallel with one request
- Reduces code complexity significantly

* fix(nip-29): use limit 1 for replaceable participant events

Kinds 39001 and 39002 are replaceable events with d-tag, so there should
only be one valid event of each kind per group. Changed limit from 5 to 1.

* fix(chat): change "Sign in to send messages" to "Sign in to post"

Simplified the login prompt text in chat interface for clarity.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-20 11:41:51 +01:00
committed by GitHub
parent c2f6f1bcd2
commit c52e783fce
2 changed files with 29 additions and 47 deletions

View File

@@ -1126,7 +1126,7 @@ export function ChatViewer({
>
Sign in
</button>{" "}
to send messages
to post
</div>
)}

View File

@@ -189,83 +189,65 @@ export class Nip29Adapter extends ChatProtocolAdapter {
console.log(`[NIP-29] Group title: ${title}`);
// Fetch admins (kind 39001) and members (kind 39002)
// Fetch admins (kind 39001) and members (kind 39002) in parallel
// Both use d tag (addressable events signed by relay)
const participantsFilter: Filter = {
kinds: [39001, 39002],
const adminsFilter: Filter = {
kinds: [39001],
"#d": [groupId],
limit: 10, // Should be 1 of each kind, but allow for duplicates
limit: 1,
};
const participantEvents: NostrEvent[] = [];
const participantsObs = pool.subscription(
[relayUrl],
[participantsFilter],
{
eventStore,
},
const membersFilter: Filter = {
kinds: [39002],
"#d": [groupId],
limit: 1,
};
// Use pool.request with both filters to fetch and auto-close on EOSE
const participantEvents = await firstValueFrom(
pool
.request([relayUrl], [adminsFilter, membersFilter], { eventStore })
.pipe(toArray()),
);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
console.log("[NIP-29] Participants fetch timeout");
resolve();
}, 5000);
console.log(`[NIP-29] Got ${participantEvents.length} participant events`);
const sub = participantsObs.subscribe({
next: (response) => {
if (typeof response === "string") {
// EOSE received
clearTimeout(timeout);
console.log(
`[NIP-29] Got ${participantEvents.length} participant events`,
);
sub.unsubscribe();
resolve();
} else {
// Event received
participantEvents.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error("[NIP-29] Participants fetch error:", err);
sub.unsubscribe();
reject(err);
},
});
});
const adminEvents = participantEvents.filter((e) => e.kind === 39001);
const memberEvents = participantEvents.filter((e) => e.kind === 39002);
// Helper to validate and normalize role names
const normalizeRole = (role: string | undefined): ParticipantRole => {
if (!role) return "member";
const normalizeRole = (
role: string | undefined,
defaultRole: ParticipantRole,
): ParticipantRole => {
if (!role) return defaultRole;
const lower = role.toLowerCase();
if (lower === "admin") return "admin";
if (lower === "moderator") return "moderator";
if (lower === "host") return "host";
// Default to member for unknown roles
return "member";
// Default to provided default for unknown roles
return defaultRole;
};
// Extract participants from both admins and members events
const participantsMap = new Map<string, Participant>();
// Process kind:39001 (admins with roles)
const adminEvents = participantEvents.filter((e) => e.kind === 39001);
// Users in kind 39001 are admins by default
for (const event of adminEvents) {
// Each p tag: ["p", "<pubkey>", "<role1>", "<role2>", ...]
for (const tag of event.tags) {
if (tag[0] === "p" && tag[1]) {
const pubkey = tag[1];
const roles = tag.slice(2).filter((r) => r); // Get all roles after pubkey
const primaryRole = normalizeRole(roles[0]); // Use first role as primary
const primaryRole = normalizeRole(roles[0], "admin"); // Default to "admin" for kind 39001
participantsMap.set(pubkey, { pubkey, role: primaryRole });
}
}
}
// Process kind:39002 (members without roles)
const memberEvents = participantEvents.filter((e) => e.kind === 39002);
// Users in kind 39002 are regular members
for (const event of memberEvents) {
// Each p tag: ["p", "<pubkey>"]
for (const tag of event.tags) {