Use relay-provided member lists as source of truth (#191)

Co-authored-by: Nayan Patidar <nayan9617@noreply.coracle.social>
Co-committed-by: Nayan Patidar <nayan9617@noreply.coracle.social>
This commit is contained in:
Nayan Patidar
2026-04-13 21:12:49 +00:00
committed by hodlbod
parent bdc8e75640
commit b46fd94578
9 changed files with 215 additions and 156 deletions
+5 -1
View File
@@ -243,7 +243,7 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if $members.length > 0} {#if $members !== undefined && $members.length > 0}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4"> <div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span>Members:</span> <span>Members:</span>
@@ -251,6 +251,10 @@
</div> </div>
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button> <Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
</div> </div>
{:else if $members === undefined}
<div class="card2 card2-sm bg-base-200 flex items-center gap-4">
<span class="text-error">Member list not available from this relay</span>
</div>
{/if} {/if}
<div class="card2 card2-sm bg-alt col-4"> <div class="card2 card2-sm bg-alt col-4">
<strong class="text-lg">Room Settings</strong> <strong class="text-lg">Room Settings</strong>
+36 -26
View File
@@ -73,34 +73,44 @@
</ModalSubtitle> </ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each $members as pubkey (pubkey)} {#if $members === undefined}
<div class="card2 bg-alt relative"> <div class="card2 bg-base-200 p-4">
<div class="flex items-center justify-between gap-2"> <span class="text-error">Member list not available from this relay</span>
<div class="min-w-0 flex-1"> </div>
<Profile {pubkey} {url} /> {:else if $members.length === 0}
</div> <div class="card2 bg-base-200 p-4">
<div class="relative"> <span class="text-base-content/70">No members yet</span>
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}> </div>
<Icon icon={MenuDots} /> {:else}
</Button> {#each $members as pubkey (pubkey)}
{#if menuPubkey === pubkey} <div class="card2 bg-alt relative">
<Popover hideOnClick onClose={closeMenu}> <div class="flex items-center justify-between gap-2">
<ul <div class="min-w-0 flex-1">
transition:fly <Profile {pubkey} {url} />
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md"> </div>
<li> <div class="relative">
<Button class="text-error" onclick={() => removeMember(pubkey)}> <Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MinusCircle} /> <Icon icon={MenuDots} />
Remove Member </Button>
</Button> {#if menuPubkey === pubkey}
</li> <Popover hideOnClick onClose={closeMenu}>
</ul> <ul
</Popover> transition:fly
{/if} class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div> </div>
</div> </div>
</div> {/each}
{/each} {/if}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+5
View File
@@ -56,6 +56,11 @@
} }
const onSubmit = async () => { const onSubmit = async () => {
if (!$spaceMembers) {
addMembers()
return
}
const pubkeysSnapshot = $state.snapshot(pubkeys) const pubkeysSnapshot = $state.snapshot(pubkeys)
const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey)) const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey))
+51 -39
View File
@@ -112,46 +112,58 @@
{/if} {/if}
{/if} {/if}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each $members as pubkey (pubkey)} {#if $members === undefined}
<div class="card2 card2-sm bg-alt relative"> <div class="card2 bg-base-200 p-4">
<div class="flex items-center justify-between gap-2"> <span class="text-error">Member list not available from this space</span>
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canBan || canUnallow}
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow}
<li>
<Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div> </div>
{/each} {:else if $members.length === 0}
<div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}
<div class="card2 card2-sm bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canBan || canUnallow}
<div class="relative">
<Button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow}
<li>
<Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+5 -1
View File
@@ -181,7 +181,11 @@
<li> <li>
<Button onclick={showMembers}> <Button onclick={showMembers}>
<Icon icon={UserRounded} /> <Icon icon={UserRounded} />
View Members ({$members.length}) {#if $members === undefined}
View Members
{:else}
View Members ({$members.length})
{/if}
</Button> </Button>
</li> </li>
{#if $userIsAdmin} {#if $userIsAdmin}
+2 -1
View File
@@ -766,9 +766,10 @@ export const addSpaceMembers = async (
pubkeys: string[], pubkeys: string[],
): Promise<string | undefined> => { ): Promise<string | undefined> => {
const spaceMembers = get(deriveSpaceMembers(url)) const spaceMembers = get(deriveSpaceMembers(url))
const results = await Promise.all( const results = await Promise.all(
pubkeys pubkeys
.filter(pubkey => !spaceMembers.includes(pubkey)) .filter(pubkey => !spaceMembers || !spaceMembers.includes(pubkey))
.map(pubkey => .map(pubkey =>
manageRelay(url, { manageRelay(url, {
method: ManagementMethod.AllowPubkey, method: ManagementMethod.AllowPubkey,
+106 -83
View File
@@ -808,36 +808,49 @@ export const deriveOtherRooms = (url: string) =>
// Space/room memberships // Space/room memberships
const getSpaceMembers = (_url: string, events: TrustedEvent[]) => { export const deriveSpaceMembers = (url: string) =>
const members = new Set<string>() derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) =>
uniq(getTagValues("member", event?.tags ?? [])),
)
for (const event of sortEventsAsc(events)) { export const deriveRoomMembers = (url: string, h: string) => {
if (event.kind === RELAY_MEMBERS) { const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}]
members.clear()
for (const pubkey of uniq(getTagValues("member", event.tags))) { return derived(deriveEventsForUrl(url, filters), ([event]) =>
members.add(pubkey) uniq(getPubkeyTagValues(event?.tags ?? [])),
} )
}
continue export type BannedPubkeyItem = {
pubkey: string
reason: string
}
export const spaceBannedPubkeyItems = new Map<string, BannedPubkeyItem[]>()
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) return []
})
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)
} }
const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => { const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
@@ -876,53 +889,6 @@ const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
return Array.from(members) 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<string, BannedPubkeyItem[]>()
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) // Action items (admin review queue)
// const pendingJoins: TrustedEvent[] = [] // const pendingJoins: TrustedEvent[] = []
@@ -1019,19 +985,49 @@ export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
}) })
export const deriveUserSpaceMembershipStatus = (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( return derived(
[ [
pubkey, pubkey,
deriveSpaceMembers(url), deriveRelaySignedEvents(url, memberListFilters),
deriveEventsForUrl(url, filters), deriveRelaySignedEvents(url, userEventFilters),
deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]),
deriveUserIsSpaceAdmin(url), deriveUserIsSpaceAdmin(url),
], ],
([$pubkey, $members, $events, $isAdmin]) => { ([$pubkey, $memberListEvents, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => {
const isMember = $members.includes($pubkey!) || $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) { if (event.pubkey !== $pubkey) {
continue continue
} }
@@ -1057,19 +1053,46 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) =>
) )
export const deriveUserRoomMembershipStatus = (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( return derived(
[ [
pubkey, pubkey,
deriveRoomMembers(url, h), deriveRoomMembers(url, h),
deriveEventsForUrl(url, filters), deriveEventsForUrl(url, userEventFilters),
deriveEventsForUrl(url, joinLeaveFilters),
deriveUserIsRoomAdmin(url, h), deriveUserIsRoomAdmin(url, h),
], ],
([$pubkey, $members, $events, $isAdmin]) => { ([$pubkey, $memberList, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => {
const isMember = $members.includes($pubkey!) || $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) { if (event.pubkey !== $pubkey) {
continue continue
} }
+4 -4
View File
@@ -284,7 +284,7 @@ const syncSpace = (url: string) => {
{kinds: [MESSAGE, ...CONTENT_KINDS], since, "#h": [room]}, {kinds: [MESSAGE, ...CONTENT_KINDS], since, "#h": [room]},
makeCommentFilter(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], "#h": [room],
}, },
{kinds: [PollResponse], since}, {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 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({ pullAndListen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...CONTENT_KINDS, MESSAGE]}, {kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
makeCommentFilter(CONTENT_KINDS, {since}), makeCommentFilter(CONTENT_KINDS, {since}),
{kinds: [PollResponse], since}, {kinds: [PollResponse], since},
], ],
+1 -1
View File
@@ -79,7 +79,7 @@ export const makeEditor = async ({
getValue: (profile: PublishedProfile) => profile.event.pubkey, getValue: (profile: PublishedProfile) => profile.event.pubkey,
sortFn: ({score = 1, item}) => { sortFn: ({score = 1, item}) => {
const wotScore = getWotGraph().get(item.event.pubkey) || 0 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 return dec(score) * inc(wotScore / getMaxWot()) * membershipScale
}, },