From b46fd945783df27eeb9a1ec73dbd8976f59799f7 Mon Sep 17 00:00:00 2001 From: Nayan Patidar Date: Mon, 13 Apr 2026 21:12:49 +0000 Subject: [PATCH] Use relay-provided member lists as source of truth (#191) Co-authored-by: Nayan Patidar Co-committed-by: Nayan Patidar --- src/app/components/RoomDetail.svelte | 6 +- src/app/components/RoomMembers.svelte | 62 ++++---- src/app/components/RoomMembersAdd.svelte | 5 + src/app/components/SpaceMembers.svelte | 90 ++++++----- src/app/components/SpaceMenu.svelte | 6 +- src/app/core/commands.ts | 3 +- src/app/core/state.ts | 189 +++++++++++++---------- src/app/core/sync.ts | 8 +- src/app/editor/index.ts | 2 +- 9 files changed, 215 insertions(+), 156 deletions(-) diff --git a/src/app/components/RoomDetail.svelte b/src/app/components/RoomDetail.svelte index 80688c7c..3115b3d5 100644 --- a/src/app/components/RoomDetail.svelte +++ b/src/app/components/RoomDetail.svelte @@ -243,7 +243,7 @@ {/if} - {#if $members.length > 0} + {#if $members !== undefined && $members.length > 0}
Members: @@ -251,6 +251,10 @@
+ {:else if $members === undefined} +
+ Member list not available from this relay +
{/if}
Room Settings diff --git a/src/app/components/RoomMembers.svelte b/src/app/components/RoomMembers.svelte index e9dd1beb..8ff478b9 100644 --- a/src/app/components/RoomMembers.svelte +++ b/src/app/components/RoomMembers.svelte @@ -73,34 +73,44 @@
- {#each $members as pubkey (pubkey)} -
-
-
- -
-
- - {#if menuPubkey === pubkey} - - - - {/if} + {#if $members === undefined} +
+ Member list not available from this relay +
+ {:else if $members.length === 0} +
+ No members yet +
+ {:else} + {#each $members as pubkey (pubkey)} +
+
+
+ +
+
+ + {#if menuPubkey === pubkey} + + + + {/if} +
-
- {/each} + {/each} + {/if}
diff --git a/src/app/components/RoomMembersAdd.svelte b/src/app/components/RoomMembersAdd.svelte index 63397e3d..e02954ad 100644 --- a/src/app/components/RoomMembersAdd.svelte +++ b/src/app/components/RoomMembersAdd.svelte @@ -56,6 +56,11 @@ } const onSubmit = async () => { + if (!$spaceMembers) { + addMembers() + return + } + const pubkeysSnapshot = $state.snapshot(pubkeys) const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey)) diff --git a/src/app/components/SpaceMembers.svelte b/src/app/components/SpaceMembers.svelte index a3eae54c..d8b311b6 100644 --- a/src/app/components/SpaceMembers.svelte +++ b/src/app/components/SpaceMembers.svelte @@ -112,46 +112,58 @@ {/if} {/if}
- {#each $members as pubkey (pubkey)} -
-
-
- -
- {#if canBan || canUnallow} -
- - {#if menuPubkey === pubkey} - - - - {/if} -
- {/if} -
+ {#if $members === undefined} +
+ Member list not available from this space
- {/each} + {:else if $members.length === 0} +
+ No members yet +
+ {:else} + {#each $members as pubkey (pubkey)} +
+
+
+ +
+ {#if canBan || canUnallow} +
+ + {#if menuPubkey === pubkey} + + + + {/if} +
+ {/if} +
+
+ {/each} + {/if}
diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 782e3509..09a12ba8 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -181,7 +181,11 @@
  • {#if $userIsAdmin} diff --git a/src/app/core/commands.ts b/src/app/core/commands.ts index 9f99c659..97a866d3 100644 --- a/src/app/core/commands.ts +++ b/src/app/core/commands.ts @@ -766,9 +766,10 @@ export const addSpaceMembers = async ( pubkeys: string[], ): Promise => { const spaceMembers = get(deriveSpaceMembers(url)) + const results = await Promise.all( pubkeys - .filter(pubkey => !spaceMembers.includes(pubkey)) + .filter(pubkey => !spaceMembers || !spaceMembers.includes(pubkey)) .map(pubkey => manageRelay(url, { method: ManagementMethod.AllowPubkey, diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 767b16a9..78e8576c 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -808,36 +808,49 @@ export const deriveOtherRooms = (url: string) => // Space/room memberships -const getSpaceMembers = (_url: string, events: TrustedEvent[]) => { - const members = new Set() +export const deriveSpaceMembers = (url: string) => + derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) => + uniq(getTagValues("member", event?.tags ?? [])), + ) - for (const event of sortEventsAsc(events)) { - if (event.kind === RELAY_MEMBERS) { - members.clear() +export const deriveRoomMembers = (url: string, h: string) => { + const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}] - for (const pubkey of uniq(getTagValues("member", event.tags))) { - members.add(pubkey) - } + return derived(deriveEventsForUrl(url, filters), ([event]) => + uniq(getPubkeyTagValues(event?.tags ?? [])), + ) +} - continue +export type BannedPubkeyItem = { + pubkey: string + reason: string +} + +export const spaceBannedPubkeyItems = new Map() + +export const deriveSpaceBannedPubkeyItems = (url: string) => { + const store = writable(spaceBannedPubkeyItems.get(url) || []) + + manageRelay(url, {method: ManagementMethod.ListBannedPubkeys, params: []}).then(res => { + spaceBannedPubkeyItems.set(url, res.result) + store.set(res.result) + }) + + return store +} + +export const deriveRoomAdmins = (url: string, h: string) => { + const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}] + + return derived(deriveEventsForUrl(url, filters), $events => { + const adminsEvent = first($events) + + if (adminsEvent) { + return getPubkeyTagValues(adminsEvent.tags) } - const pubkeys = getPubkeyTagValues(event.tags) - - if (event.kind === RELAY_ADD_MEMBER) { - for (const pubkey of pubkeys) { - members.add(pubkey) - } - } - - if (event.kind === RELAY_REMOVE_MEMBER) { - for (const pubkey of pubkeys) { - members.delete(pubkey) - } - } - } - - return Array.from(members) + return [] + }) } const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => { @@ -876,53 +889,6 @@ const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => { return Array.from(members) } -export const deriveSpaceMembers = (url: string) => - derived( - deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]), - $events => getSpaceMembers(url, $events), - ) - -export type BannedPubkeyItem = { - pubkey: string - reason: string -} - -export const spaceBannedPubkeyItems = new Map() - -export const deriveSpaceBannedPubkeyItems = (url: string) => { - const store = writable(spaceBannedPubkeyItems.get(url) || []) - - manageRelay(url, {method: ManagementMethod.ListBannedPubkeys, params: []}).then(res => { - spaceBannedPubkeyItems.set(url, res.result) - store.set(res.result) - }) - - return store -} - -export const deriveRoomMembers = (url: string, h: string) => { - const filters: Filter[] = [ - {kinds: [ROOM_MEMBERS], "#d": [h]}, - {kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}, - ] - - return derived(deriveEventsForUrl(url, filters), $events => getRoomMembers(url, h, $events)) -} - -export const deriveRoomAdmins = (url: string, h: string) => { - const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}] - - return derived(deriveEventsForUrl(url, filters), $events => { - const adminsEvent = first($events) - - if (adminsEvent) { - return getPubkeyTagValues(adminsEvent.tags) - } - - return [] - }) -} - // Action items (admin review queue) // const pendingJoins: TrustedEvent[] = [] @@ -1019,19 +985,49 @@ export const deriveUserIsSpaceAdmin = memoize((url?: string) => { }) export const deriveUserSpaceMembershipStatus = (url: string) => { - const filters: Filter[] = [{kinds: [RELAY_JOIN, RELAY_LEAVE]}] + // Fetch member list and user add/remove events directly in this derivation. + const memberListFilters: Filter[] = [{kinds: [RELAY_MEMBERS]}] + const userEventFilters: Filter[] = [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}] return derived( [ pubkey, - deriveSpaceMembers(url), - deriveEventsForUrl(url, filters), + deriveRelaySignedEvents(url, memberListFilters), + deriveRelaySignedEvents(url, userEventFilters), + deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]), deriveUserIsSpaceAdmin(url), ], - ([$pubkey, $members, $events, $isAdmin]) => { - const isMember = $members.includes($pubkey!) || $isAdmin + ([$pubkey, $memberListEvents, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => { + // If admin, always granted. + if ($isAdmin) { + return MembershipStatus.Granted + } - for (const event of $events) { + const membersEvent = $memberListEvents.find(spec({kind: RELAY_MEMBERS})) + const memberList = membersEvent ? uniq(getTagValues("member", membersEvent.tags)) : undefined + + let isMember = false + + if (memberList) { + // Member list exists - check if user is in it. + isMember = memberList.includes($pubkey!) + } else { + // No member list available - replay the user's add/remove history. + for (const event of sortBy(e => e.created_at, $userAddRemoveEvents)) { + if (event.pubkey !== $pubkey) { + continue + } + + if (event.kind === RELAY_ADD_MEMBER) { + isMember = true + } else if (event.kind === RELAY_REMOVE_MEMBER) { + isMember = false + } + } + } + + for (const event of $joinLeaveEvents) { + // Join events indicate pending or granted status, leave resets to initial. if (event.pubkey !== $pubkey) { continue } @@ -1057,19 +1053,46 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) => ) export const deriveUserRoomMembershipStatus = (url: string, h: string) => { - const filters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}] + // Fetch the room member list and the current user's add/remove events. + const userEventFilters: Filter[] = [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}] + const joinLeaveFilters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}] return derived( [ pubkey, deriveRoomMembers(url, h), - deriveEventsForUrl(url, filters), + deriveEventsForUrl(url, userEventFilters), + deriveEventsForUrl(url, joinLeaveFilters), deriveUserIsRoomAdmin(url, h), ], - ([$pubkey, $members, $events, $isAdmin]) => { - const isMember = $members.includes($pubkey!) || $isAdmin + ([$pubkey, $memberList, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => { + // If admin of this room's space, always granted. + if ($isAdmin) { + return MembershipStatus.Granted + } - for (const event of $events) { + let isMember = false + + if ($memberList) { + // Member list exists - check if user is in it. + isMember = $memberList.includes($pubkey!) + } else { + // No member list available - replay the user's add/remove history. + for (const event of sortEventsAsc($userAddRemoveEvents)) { + if (event.pubkey !== $pubkey) { + continue + } + + if (event.kind === ROOM_ADD_MEMBER) { + isMember = true + } else if (event.kind === ROOM_REMOVE_MEMBER) { + isMember = false + } + } + } + + for (const event of $joinLeaveEvents) { + // Join events indicate pending or granted status, leave resets to initial. if (event.pubkey !== $pubkey) { continue } diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index 3d660553..ff1f33c9 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -284,7 +284,7 @@ const syncSpace = (url: string) => { {kinds: [MESSAGE, ...CONTENT_KINDS], since, "#h": [room]}, makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}), { - kinds: [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], + kinds: [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE], "#h": [room], }, {kinds: [PollResponse], since}, @@ -293,15 +293,15 @@ const syncSpace = (url: string) => { } } - const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER] + const relayKinds = [RELAY_MEMBERS] const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS] - const roomMemberKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER] + const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE] pullAndListen({ url, signal: controller.signal, filters: [ - {kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...CONTENT_KINDS, MESSAGE]}, + {kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]}, makeCommentFilter(CONTENT_KINDS, {since}), {kinds: [PollResponse], since}, ], diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index c4e5df3f..9a787c4b 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -79,7 +79,7 @@ export const makeEditor = async ({ getValue: (profile: PublishedProfile) => profile.event.pubkey, sortFn: ({score = 1, item}) => { const wotScore = getWotGraph().get(item.event.pubkey) || 0 - const membershipScale = $spaceMembers.includes(item.event.pubkey) ? 2 : 1 + const membershipScale = $spaceMembers?.includes(item.event.pubkey) ? 2 : 1 return dec(score) * inc(wotScore / getMaxWot()) * membershipScale },