feat(rbac): implement NIP-29 room roles and permission gating (#47) #220

Closed
Khushvendra wants to merge 5 commits from Khushvendra/flotilla:feat/nip29-rbac-47 into dev
11 changed files with 951 additions and 140 deletions
Showing only changes of commit 559db6b930 - Show all commits
+5 -3
View File
@@ -3,7 +3,7 @@
import type {Snippet} from "svelte"
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT, ManagementMethod} from "@welshman/util"
import {COMMENT, ManagementMethod, getTagValue} from "@welshman/util"
import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
@@ -18,6 +18,7 @@
import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {hasNip29, deriveUserIsSpaceAdmin} from "@app/core/state"
import {hasPermission} from "@app/core/roles"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {makeSpaceChatPath} from "@app/util/routes"
@@ -33,7 +34,8 @@
const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const h = getTagValue("h", event.tags)
const canDelete = h ? hasPermission(url, h, 9005) : deriveUserIsSpaceAdmin(url)
const report = () => pushModal(Report, {url, event})
1
@@ -107,7 +109,7 @@
Report Content
</Button>
</li>
{#if $userIsAdmin}
{#if $canDelete}
<li>
<Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} />
+31 -12
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {readable} from "svelte/store"
import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util"
import {
@@ -28,7 +29,9 @@
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import RoleBadge from "@app/components/RoleBadge.svelte"
import {pubkeyLink, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {deriveUserHasSpacePermission, deriveSpaceMemberRoleInfo} from "@app/core/roles"
import {addSpaceMembers} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -43,10 +46,16 @@
const profile = deriveProfile(pubkey, removeUndefined([url]))
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const canBan = url ? deriveUserHasSpacePermission(url, 9009) : readable(false)
const canRestore = url ? deriveUserHasSpacePermission(url, 9000) : readable(false)
const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined
const spaceMemberRoles = url ? deriveSpaceMemberRoleInfo(url) : readable(new Map())
const assignedRoles = $derived($spaceMemberRoles.get(pubkey)?.roles || [])
const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false)
const back = () => history.back()
@@ -105,7 +114,7 @@
<div class="flex flex-col gap-4">
<div class="flex justify-between">
<Profile showPubkey avatarSize={14} {pubkey} {url} />
{#if $profile || $userIsAdmin}
{#if $profile || $canBan || $canRestore}
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
@@ -123,22 +132,22 @@
</Button>
</li>
{/if}
{#if $userIsAdmin}
{#if isBanned}
{#if isBanned}
{#if $canRestore}
<li>
<Button onclick={restoreMember}>
<Icon icon={Restart} />
Restore User
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{:else if $canBan}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
@@ -147,6 +156,16 @@
{/if}
</div>
<ProfileInfo {pubkey} {url} />
{#if assignedRoles.length > 0}
<div class="card2 card2-sm bg-alt col-3">
<h3 class="text-lg font-semibold">Roles</h3>
<div class="flex flex-wrap gap-2">
{#each assignedRoles as role (role.name)}
<RoleBadge role={role.name} label={role.label} color={role.color} class="badge-md" />
{/each}
</div>
</div>
{/if}
<ProfileBadges {pubkey} {url} />
</div>
</ModalBody>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import cx from "classnames"
import {roleColorToCSS} from "@app/core/roles"
type Props = {
role: string
label?: string
color?: number
class?: string
}
const {role, label, color, ...props}: Props = $props()
const style = $derived(
color === undefined
? ""
: `color: ${roleColorToCSS(color)}; border-color: ${roleColorToCSS(color)};`,
)
const className = $derived(cx("badge badge-outline badge-sm", props.class))
</script>
<span class={className} {style}>
{label || role}
</span>
+48 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {sortBy} from "@welshman/lib"
import {goto} from "$app/navigation"
import type {RoomMeta} from "@welshman/util"
import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
@@ -27,10 +28,12 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import RoomMembers from "@app/components/RoomMembers.svelte"
import RoomEdit from "@app/components/RoomEdit.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import {deriveRoomRoles, hasPermission} from "@app/core/roles"
import {
deriveRoom,
deriveRoomMembers,
@@ -58,13 +61,29 @@
const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h)
const roomRoles = deriveRoomRoles(url, h)
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const canEditMetadata = hasPermission(url, h, 9002)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const userRooms = deriveUserRooms(url)
const isFavorite = $derived($userRooms.includes(h))
const shouldNotify = deriveShouldNotify(url, h)
const roleRows = $derived.by(() =>
sortBy(
role => -(role.order ?? -Infinity),
Array.from($roomRoles.roles.values()).map(role => ({
name: role.name,
label: role.label,
color: role.color,
order: role.order,
permissionsLabel: role.permissions.join(", "),
accessLabel: Array.from(role.access).join(", "),
})),
),
)
const back = () => history.back()
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Instead of mapping to an ad hoc data type, I would just defer the display logic until it's needed. You could add helper functions if needed.

Instead of mapping to an ad hoc data type, I would just defer the display logic until it's needed. You could add helper functions if needed.
const toggleMenu = () => {
@@ -152,7 +171,7 @@
<ul
transition:fly
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
{#if $userIsAdmin}
{#if $canEditMetadata}
<li>
<Button onclick={startEdit}>
<Icon icon={Pen} />
@@ -247,7 +266,7 @@
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<span>Members:</span>
<ProfileCircles pubkeys={$members} />
<ProfileCircles pubkeys={$members.map(member => member.pubkey)} />
</div>
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
</div>
@@ -256,6 +275,33 @@
<span class="text-error">Member list not available from this relay</span>
</div>
{/if}
{#if $userIsAdmin && roleRows.length > 0}
<div class="card2 card2-sm bg-alt col-4">
<strong class="text-lg">Role Definitions</strong>
<div class="flex flex-col gap-2">
{#each roleRows as role (role.name)}
<div class="rounded-box bg-base-300 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2">
<RoleBadge
role={role.name}
label={role.label}
color={role.color}
class="badge-md" />
{#if role.order !== undefined}
<span class="text-xs opacity-70">Order {role.order}</span>
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Since role is a defined data type, I would just pass the whole thing in and RoleBadge can accept a RoleDefinition

Since role is a defined data type, I would just pass the whole thing in and RoleBadge can accept a `RoleDefinition`
{/if}
</div>
{#if role.permissionsLabel}
<p class="text-xs opacity-75">Permissions: {role.permissionsLabel}</p>
{/if}
{#if role.accessLabel}
<p class="text-xs opacity-75">Access: {role.accessLabel}</p>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<div class="card2 card2-sm bg-alt col-4">
<strong class="text-lg">Room Settings</strong>
<div class="flex items-center justify-between">
+113 -27
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {first, sortBy} from "@welshman/lib"
import {waitForThunkError, removeRoomMember} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
@@ -16,9 +17,12 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import Profile from "@app/components/Profile.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomMembersAdd from "@app/components/RoomMembersAdd.svelte"
import {deriveRoom, deriveRoomMembers, deriveUserIsRoomAdmin} from "@app/core/state"
import type {RoomMember} from "@app/core/roles"
import {deriveRoom, deriveRoomMembers} from "@app/core/state"
import {deriveRoomRoles, hasPermission} from "@app/core/roles"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -31,7 +35,9 @@
const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h)
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const roomRoles = deriveRoomRoles(url, h)
const canAddMembers = hasPermission(url, h, 9000)
const canRemoveMembers = hasPermission(url, h, 9001)
const back = () => history.back()
@@ -43,6 +49,62 @@
menuPubkey = undefined
}
const getResolvedRoles = (member: RoomMember) =>
removeUndefined(member.roles.map(roleName => $roomRoles.roles.get(roleName)))
const getPrimaryRole = (member: RoomMember) =>
first(sortBy(role => -(role.order ?? -Infinity), getResolvedRoles(member)))
const memberGroups = $derived.by(() => {
const byRole = new Map<
string,
{
key: string
label: string
color?: number
order?: number
members: RoomMember[]
}
>()
const defaultGroup = {
key: "members",
label: "Members",
members: [] as RoomMember[],
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Try to re-use defined types when possible. This mostly overlaps with RoleDefinition.

Try to re-use defined types when possible. This mostly overlaps with RoleDefinition.
}
for (const member of $members) {
const primaryRole = getPrimaryRole(member)
if (!primaryRole) {
defaultGroup.members.push(member)
continue
}
if (!byRole.has(primaryRole.name)) {
byRole.set(primaryRole.name, {
key: primaryRole.name,
label: primaryRole.label || primaryRole.name,
color: primaryRole.color,
order: primaryRole.order,
members: [],
})
}
byRole.get(primaryRole.name)!.members.push(member)
}
const groups = sortBy(group => -(group.order ?? -Infinity), Array.from(byRole.values()))
if (defaultGroup.members.length > 0) {
groups.push(defaultGroup)
}
return groups
})
const removeUndefined = <T,>(items: Array<T | undefined>): T[] =>
items.filter((item): item is T => item !== undefined)
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

This exists already in @welshman/lib

This exists already in @welshman/lib
const addMember = () => pushModal(RoomMembersAdd, {url, h})
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

This whole thing could probably go in the roles.ts file. See if you can define types that make sense in this context. The fewer named (and especially ad-hoc) types, the easier the code is to read.

This whole thing could probably go in the `roles.ts` file. See if you can define types that make sense in this context. The fewer named (and especially ad-hoc) types, the easier the code is to read.
const removeMember = (pubkey: string) =>
@@ -82,33 +144,57 @@
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<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">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{#each memberGroups as group (group.key)}
<div class="pt-2 pb-1">
{#if group.color !== undefined}
<RoleBadge
role={group.key}
label={group.label}
color={group.color}
class="badge-md" />
{:else}
<span class="text-sm font-semibold opacity-75">{group.label}</span>
{/if}
</div>
{#each group.members as member (member.pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile pubkey={member.pubkey} {url} />
{#if getResolvedRoles(member).length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each getResolvedRoles(member) as role (role.name)}
<RoleBadge role={role.name} label={role.label} color={role.color} />
{/each}
</div>
{/if}
</div>
{#if $canRemoveMembers}
<div class="relative">
<Button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(member.pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === member.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">
<li>
<Button class="text-error" onclick={() => removeMember(member.pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/each}
{/each}
{/if}
</div>
@@ -118,7 +204,7 @@
<Icon icon={AltArrowLeft} />
Go back
</Button>
{#if $userIsAdmin}
{#if $canAddMembers}
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
+3 -3
View File
@@ -18,7 +18,7 @@
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileLatest from "@app/components/ProfileLatest.svelte"
import {deriveUserIsSpaceAdmin} from "@app/core/state"
import {deriveUserHasSpacePermission} from "@app/core/roles"
import {pushModal} from "@app/util/modal"
type Props = {
@@ -28,7 +28,7 @@
const {url}: Props = $props()
const relay = deriveRelay(url)
const owner = $derived($relay?.pubkey)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const canEdit = deriveUserHasSpacePermission(url, 9002)
const back = () => history.back()
@@ -54,7 +54,7 @@
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
</div>
</div>
{#if $userIsAdmin}
{#if $canEdit}
<Button class="btn btn-primary" onclick={startEdit}>
<Icon icon={Pen} />
Edit
+140 -44
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {sortBy} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util"
import {manageRelay, displayProfileByPubkey} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
@@ -17,16 +18,18 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import RelayName from "@app/components/RelayName.svelte"
import Profile from "@app/components/Profile.svelte"
import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import type {RoomMember} from "@app/core/roles"
import {
deriveSpaceMembers,
deriveSpaceBannedPubkeyItems,
deriveUserIsSpaceAdmin,
deriveSupportedMethods,
} from "@app/core/state"
import {deriveSpaceMemberRoleInfo, deriveUserHasSpacePermission} from "@app/core/roles"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -38,10 +41,83 @@
const members = deriveSpaceMembers(url)
const bans = deriveSpaceBannedPubkeyItems(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const spaceMemberRoles = deriveSpaceMemberRoleInfo(url)
const canAddMember = deriveUserHasSpacePermission(url, 9000)
const canBanByPermission = deriveUserHasSpacePermission(url, 9009)
const canUnallowByPermission = deriveUserHasSpacePermission(url, 9001)
const supportedMethods = deriveSupportedMethods(url)
Review

I think this is mixing different concerns, the ability for a relay admin to ban/add relay members has nothing to do with a group admin's ability to ban/add group members. These actions should be based exclusively on nip 86 management methods (or equivalent rbac that we're planning to add to nip 43). Looking more closely at this whole PR, I am afraid we're creating a confusing mess — room roles should only apply to rooms, not spaces.

I think this is mixing different concerns, the ability for a relay admin to ban/add relay members has nothing to do with a group admin's ability to ban/add group members. These actions should be based exclusively on nip 86 management methods (or equivalent rbac that we're planning to add to nip 43). Looking more closely at this whole PR, I am afraid we're creating a confusing mess — room roles should only apply to rooms, not spaces.
const canBan = $derived($supportedMethods.includes(ManagementMethod.BanPubkey))
const canUnallow = $derived($supportedMethods.includes(ManagementMethod.UnallowPubkey))
const canBan = $derived(
$canBanByPermission && $supportedMethods.includes(ManagementMethod.BanPubkey),
)
const canUnallow = $derived(
$canUnallowByPermission && $supportedMethods.includes(ManagementMethod.UnallowPubkey),
)
type SpaceMemberWithRoles = RoomMember & {
roleDefinitions: Array<{name: string; label?: string; color?: number; order?: number}>
primaryRole?: {name: string; label?: string; color?: number}
sortKey: number
}
const memberGroups = $derived.by(() => {
const byRole = new Map<
string,
{
key: string
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Same thing, see if you can adjust the data model so that this use case falls out naturally instead of being shoehorned in.

Same thing, see if you can adjust the data model so that this use case falls out naturally instead of being shoehorned in.
label: string
color?: number
order?: number
members: SpaceMemberWithRoles[]
}
>()
const defaultGroup = {
key: "members",
label: "Members",
members: [] as SpaceMemberWithRoles[],
}
for (const pubkey of $members) {
const roleInfo = $spaceMemberRoles.get(pubkey)
const member = {
pubkey,
roles: roleInfo?.roles.map(role => role.name) || [],
roleDefinitions: roleInfo?.roles || [],
primaryRole: roleInfo?.primaryRole,
sortKey: roleInfo?.sortKey ?? -Infinity,
}
if (!member.primaryRole) {
defaultGroup.members.push(member)
continue
}
const roleName = member.primaryRole.name
if (!byRole.has(roleName)) {
byRole.set(roleName, {
key: roleName,
label: member.primaryRole.label || roleName,
color: member.primaryRole.color,
order: member.sortKey,
members: [],
})
}
byRole.get(roleName)!.members.push(member)
}
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Lots of copying, this could be much simplified if you can make the data model flow

Lots of copying, this could be much simplified if you can make the data model flow
const groups = sortBy(group => -(group.order ?? -Infinity), Array.from(byRole.values()))
for (const group of groups) {
group.members = sortBy(member => -member.sortKey, group.members)
}
if (defaultGroup.members.length > 0) {
groups.push(defaultGroup)
}
return groups
})
const back = () => history.back()
@@ -104,7 +180,7 @@
<ModalTitle>Members</ModalTitle>
<ModalSubtitle>of <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
{#if $userIsAdmin}
{#if canBan || canUnallow}
{#if $bans.length > 0}
<Button class="btn btn-neutral" onclick={showBannedPubkeyItems}>
Banned users ({$bans.length})
@@ -121,47 +197,67 @@
<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>
{#each memberGroups as group (group.key)}
<div class="pt-2 pb-1">
{#if group.color !== undefined}
<RoleBadge
role={group.key}
label={group.label}
color={group.color}
class="badge-md" />
{:else}
<span class="text-sm font-semibold opacity-75">{group.label}</span>
{/if}
</div>
{#each group.members as member (member.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={member.pubkey} {url} />
{#if member.roleDefinitions.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each member.roleDefinitions as role (role.name)}
<RoleBadge role={role.name} label={role.label} color={role.color} />
{/each}
</div>
{/if}
</div>
{/if}
{#if canBan || canUnallow}
<div class="relative">
<Button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(member.pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === member.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(member.pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(member.pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/each}
{/each}
{/if}
</div>
@@ -171,7 +267,7 @@
<Icon icon={AltArrowLeft} />
Go back
</Button>
{#if $userIsAdmin}
{#if $canAddMember}
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
+542
View File
@@ -0,0 +1,542 @@
import {derived, readable} from "svelte/store"
import {first, memoize, simpleCache, sortBy, uniq} from "@welshman/lib"
import {deriveArray, deriveEventsByIdForUrl} from "@welshman/store"
import {pubkey, repository, tracker, manageRelay} from "@welshman/app"
import {ManagementMethod, ROOM_ADMINS, ROOM_MEMBERS, getTagValue, isRelayUrl} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
export const ROOM_ROLES = 39003
const ALL_ROOM_PERMISSIONS = [9000, 9001, 9002, 9005, 9009]
export type RoleAccess = "read" | "write" | "join"
export type RoleDefinition = {
name: string
label?: string
color?: number
order?: number
permissions: number[]
access: Set<RoleAccess>
}
export type RoomRoles = {
url: string
h: string
roles: Map<string, RoleDefinition>
}
export type RoomMember = {
pubkey: string
roles: string[]
}
type ParsedRoleState = {
roles: Map<string, RoleDefinition>
hasPermissionTags: boolean
}
type RoomSnapshot = {
h: string
rolesState: ParsedRoleState
members: RoomMember[]
admins: RoomMember[]
}
export type SpaceMemberRoleInfo = {
roles: RoleDefinition[]
primaryRole?: RoleDefinition
sortKey: number
}
type SpaceRoleState = {
hasPermissionTags: boolean
userPermissions: Set<number>
memberRoles: Map<string, RoleDefinition[]>
}
const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
const makeRoleDefinition = (name: string): RoleDefinition => ({
name,
permissions: [],
access: new Set<RoleAccess>(),
})
const ensureRole = (roles: Map<string, RoleDefinition>, roleName: string) => {
if (!roles.has(roleName)) {
roles.set(roleName, makeRoleDefinition(roleName))
}
return roles.get(roleName)!
}
const asNumber = (value: string | undefined) => {
const n = parseInt(value || "")
return isNaN(n) ? undefined : n
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

These types are the place where the most time should be spent on the next revision. These types aren't bad, but I think we could cut probably ~100 LOC by making them cleaner. I don't have time right now to give it the necessary thought though.

These types are the place where the most time should be spent on the next revision. These types aren't bad, but I think we could cut probably ~100 LOC by making them cleaner. I don't have time right now to give it the necessary thought though.
Outdated
Review

ill have a look into this (less code = always better)

ill have a look into this (less code = always better)
}
const asAccess = (value: string | undefined): RoleAccess | undefined => {
if (value === "read" || value === "write" || value === "join") {
return value
}
}
const getLatestEventByKind = (events: TrustedEvent[], kind: number, h: string) =>
first(
sortBy(
event => -event.created_at,
events.filter(event => event.kind === kind && getTagValue("d", event.tags) === h),
),
)
const parseRoleState = (event?: TrustedEvent): ParsedRoleState => {
const roles = new Map<string, RoleDefinition>()
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

This function exists already in core/state

This function exists already in core/state
let hasPermissionTags = false
let activeRoleName: string | undefined
for (const tag of event?.tags || []) {
const [name, firstValue, secondValue] = tag
if (name === "role") {
if (firstValue) {
activeRoleName = firstValue
ensureRole(roles, firstValue)
}
continue
}
if (
!["role-label", "role-color", "role-order", "role-permission", "role-access"].includes(name)
) {
continue
}
const hasExplicitRole = Boolean(firstValue && secondValue !== undefined)
const roleName = hasExplicitRole ? firstValue : activeRoleName
const value = hasExplicitRole ? secondValue : firstValue
if (!roleName || !value) {
continue
}
const role = ensureRole(roles, roleName)
if (name === "role-label") {
role.label = value
continue
}
if (name === "role-color") {
const color = asNumber(value)
if (color !== undefined && color >= 0 && color <= 255) {
role.color = color
}
continue
}
if (name === "role-order") {
const order = asNumber(value)
if (order !== undefined) {
role.order = order
}
continue
}
if (name === "role-permission") {
const permission = asNumber(value)
hasPermissionTags = true
if (permission !== undefined && !role.permissions.includes(permission)) {
role.permissions.push(permission)
}
continue
}
if (name === "role-access") {
const access = asAccess(value)
if (access) {
role.access.add(access)
}
}
}
return {roles, hasPermissionTags}
}
const getRoleTokens = (tag: string[]) => {
const roles: string[] = []
for (const value of tag.slice(2)) {
if (!value || isRelayUrl(value)) {
continue
}
roles.push(value)
}
return uniq(roles)
}
export const parseRoomMembers = (tags: string[][]) => {
const byPubkey = new Map<string, Set<string>>()
for (const tag of tags) {
if (tag[0] !== "p" || !tag[1]) {
continue
}
if (!byPubkey.has(tag[1])) {
byPubkey.set(tag[1], new Set<string>())
}
const roles = byPubkey.get(tag[1])!
for (const role of getRoleTokens(tag)) {
roles.add(role)
}
}
return Array.from(byPubkey.entries()).map(([pubkey, roles]) => ({
pubkey,
roles: Array.from(roles),
}))
}
const deriveRoomListStore = simpleCache(([url, h]: [string, string]) =>
deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS, ROOM_ADMINS], "#d": [h]}]),
)
export const deriveRoomMembers = (url: string, h: string) =>
derived(deriveRoomListStore(url, h), $events => {
const event = getLatestEventByKind($events, ROOM_MEMBERS, h)
return parseRoomMembers(event?.tags || [])
})
export const deriveRoomAdmins = (url: string, h: string) =>
derived(deriveRoomListStore(url, h), $events => {
const event = getLatestEventByKind($events, ROOM_ADMINS, h)
return parseRoomMembers(event?.tags || [])
})
const deriveRoomRoleState = simpleCache(([url, h]: [string, string]) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ROLES], "#d": [h]}]), $events =>
parseRoleState(getLatestEventByKind($events, ROOM_ROLES, h)),
),
)
export const deriveRoomRoles = (url: string, h: string) =>
derived(deriveRoomRoleState(url, h), $state => ({
url,
h,
roles: $state.roles,
}))
const getMember = (members: RoomMember[], targetPubkey: string) =>
members.find(member => member.pubkey === targetPubkey)
const getResolvedRoles = (rolesByName: Map<string, RoleDefinition>, roleNames: string[]) =>
removeUndefined(roleNames.map(name => rolesByName.get(name)))
const getPrimaryRole = (roles: RoleDefinition[]) =>
first(sortBy(role => -(role.order ?? -Infinity), roles))
const removeUndefined = <T>(items: Array<T | undefined>): T[] =>
items.filter((item): item is T => item !== undefined)
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

This could be simplified to

derived(
  deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS], '#d': [h]}]),
  ([event]) => {

The reason being that Repository handles replaceable event deduplication.

This could be simplified to ``` derived( deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS], '#d': [h]}]), ([event]) => { ``` The reason being that Repository handles replaceable event deduplication.
const deriveRoomRoleAssignments = simpleCache(([url, h]: [string, string]) =>
derived(
[deriveRoomRoleState(url, h), deriveRoomMembers(url, h), deriveRoomAdmins(url, h)],
([$rolesState, $members, $admins]) => ({
rolesState: $rolesState,
members: $members,
admins: $admins,
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Same thing here, which means you can remove deriveRoomListStore

Same thing here, which means you can remove `deriveRoomListStore`
}),
),
)
export const deriveUserRoles = (url: string, h: string, targetPubkey: string) =>
derived(deriveRoomRoleAssignments(url, h), ({members, admins}) => {
const member = getMember(members, targetPubkey)
const admin = getMember(admins, targetPubkey)
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Same here, no need for getLatest, just use the zero or one event provided. That means you can remove getLatestEventByKind too

Same here, no need for getLatest, just use the zero or one event provided. That means you can remove `getLatestEventByKind` too
return uniq([...(member?.roles || []), ...(admin?.roles || [])])
})
export const deriveUserPermissions = (url: string, h: string) =>
derived(
[pubkey, deriveRoomRoleAssignments(url, h)],
([$pubkey, {rolesState, members, admins}]) => {
const permissions = new Set<number>()
if (!$pubkey) {
return permissions
}
const member = getMember(members, $pubkey)
const admin = getMember(admins, $pubkey)
const assignedRoleNames = uniq([...(member?.roles || []), ...(admin?.roles || [])])
if (!rolesState.hasPermissionTags) {
if (admin) {
for (const permission of ALL_ROOM_PERMISSIONS) {
permissions.add(permission)
}
}
return permissions
}
for (const role of getResolvedRoles(rolesState.roles, assignedRoleNames)) {
for (const permission of role.permissions) {
permissions.add(permission)
}
}
return permissions
},
)
const deriveNip86SpaceAdmin = simpleCache(([url]: [string]) =>
readable(false, set => {
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []})
.then(({result = []}) => {
set(Boolean(result.length))
})
.catch(() => {
set(false)
})
}),
)
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

This is redundant with deriveSupportedMethods in core/state.

This is redundant with `deriveSupportedMethods` in core/state.
const buildRoomSnapshots = (events: TrustedEvent[]) => {
const latestByH = new Map<
string,
{roles?: TrustedEvent; members?: TrustedEvent; admins?: TrustedEvent}
>()
for (const event of sortBy(x => -x.created_at, events)) {
const h = getTagValue("d", event.tags)
if (!h) {
continue
}
if (!latestByH.has(h)) {
latestByH.set(h, {})
}
const entry = latestByH.get(h)!
if (event.kind === ROOM_ROLES && !entry.roles) {
entry.roles = event
}
if (event.kind === ROOM_MEMBERS && !entry.members) {
entry.members = event
}
if (event.kind === ROOM_ADMINS && !entry.admins) {
entry.admins = event
}
}
const snapshots: RoomSnapshot[] = []
for (const [h, {roles, members, admins}] of latestByH.entries()) {
snapshots.push({
h,
rolesState: parseRoleState(roles),
members: parseRoomMembers(members?.tags || []),
admins: parseRoomMembers(admins?.tags || []),
})
}
return snapshots
}
const mergeRoleDefinitions = (left: RoleDefinition[], right: RoleDefinition[]) => {
const merged = new Map<string, RoleDefinition>()
for (const role of [...left, ...right]) {
if (!merged.has(role.name)) {
merged.set(role.name, {
name: role.name,
label: role.label,
color: role.color,
order: role.order,
permissions: [...role.permissions],
access: new Set(role.access),
})
continue
}
const existing = merged.get(role.name)!
if (existing.label === undefined) {
existing.label = role.label
}
if (existing.color === undefined) {
existing.color = role.color
}
if (existing.order === undefined) {
existing.order = role.order
}
existing.permissions = uniq([...existing.permissions, ...role.permissions])
for (const access of role.access) {
existing.access.add(access)
}
}
return sortBy(role => role.name, Array.from(merged.values()))
}
const deriveSpaceRoleState = simpleCache(([url]: [string]) =>
derived(
[pubkey, deriveEventsForUrl(url, [{kinds: [ROOM_ROLES, ROOM_MEMBERS, ROOM_ADMINS]}])],
([$pubkey, $events]): SpaceRoleState => {
const userPermissions = new Set<number>()
const memberRoles = new Map<string, RoleDefinition[]>()
const snapshots = buildRoomSnapshots($events)
const hasPermissionTags = snapshots.some(snapshot => snapshot.rolesState.hasPermissionTags)
for (const snapshot of snapshots) {
const allMembers = [...snapshot.members, ...snapshot.admins]
for (const member of allMembers) {
const resolvedRoles = getResolvedRoles(snapshot.rolesState.roles, member.roles)
if (resolvedRoles.length === 0) {
continue
}
memberRoles.set(
member.pubkey,
mergeRoleDefinitions(memberRoles.get(member.pubkey) || [], resolvedRoles),
)
if ($pubkey === member.pubkey && hasPermissionTags) {
for (const role of resolvedRoles) {
for (const permission of role.permissions) {
userPermissions.add(permission)
}
}
}
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

I don't understand the purpose of snapshots, couldn't you just use the latest event of each kind and parse members on demand? Or do you expect that to be computationally intensive? It seems odd to couple roles/members/admins like this instead of access each individually. Since this function is used in only one place it might just make sense to fold the logic in there to avoid the entire idea of snapshots.

I don't understand the purpose of snapshots, couldn't you just use the latest event of each kind and parse members on demand? Or do you expect that to be computationally intensive? It seems odd to couple roles/members/admins like this instead of access each individually. Since this function is used in only one place it might just make sense to fold the logic in there to avoid the entire idea of snapshots.
}
}
return {
hasPermissionTags,
userPermissions,
memberRoles,
}
},
),
)
export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
if (!url) {
return readable(false)
}
return derived(
[deriveSpaceRoleState(url), deriveNip86SpaceAdmin(url)],
([$spaceRoleState, $nip86Admin]) => {
if ($spaceRoleState.hasPermissionTags) {
return $spaceRoleState.userPermissions.size > 0
}
return $nip86Admin
},
)
})
export const deriveUserSpacePermissions = (url: string) =>
derived(
[deriveSpaceRoleState(url), deriveUserIsSpaceAdmin(url)],
([$spaceRoleState, $isAdmin]) => {
const permissions = new Set<number>()
if ($spaceRoleState.hasPermissionTags) {
for (const permission of $spaceRoleState.userPermissions) {
permissions.add(permission)
}
return permissions
}
if ($isAdmin) {
for (const permission of ALL_ROOM_PERMISSIONS) {
permissions.add(permission)
}
}
return permissions
},
)
export const deriveUserHasSpacePermission = (url: string, kind: number) =>
derived(deriveUserSpacePermissions(url), $permissions => $permissions.has(kind))
export const deriveUserIsRoomAdmin = (url: string, h: string) =>
derived(
[deriveUserPermissions(url, h), deriveUserIsSpaceAdmin(url)],
([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.size > 0,
)
export const hasPermission = (url: string, h: string, kind: number) =>
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

should be deriveHasPermission

should be deriveHasPermission
derived(
[deriveUserPermissions(url, h), deriveUserIsSpaceAdmin(url)],
([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.has(kind),
)
export const deriveUserRoleColor = (url: string, h: string, targetPubkey: string) =>
derived(
[deriveUserRoles(url, h, targetPubkey), deriveRoomRoles(url, h)],
([$roleNames, $roomRoles]) =>
getPrimaryRole(getResolvedRoles($roomRoles.roles, $roleNames))?.color,
)
export const getRoleSortKey = (url: string, h: string, targetPubkey: string) =>
derived(
[deriveUserRoles(url, h, targetPubkey), deriveRoomRoles(url, h)],
([$roleNames, $roomRoles]) =>
getPrimaryRole(getResolvedRoles($roomRoles.roles, $roleNames))?.order,
)
export const deriveSpaceMemberRoleInfo = (url: string) =>
derived(deriveSpaceRoleState(url), $spaceRoleState => {
const roleInfoByPubkey = new Map<string, SpaceMemberRoleInfo>()
for (const [pubkey, roles] of $spaceRoleState.memberRoles.entries()) {
const sortedRoles = sortBy(role => -(role.order ?? -Infinity), roles)
const primaryRole = first(sortedRoles)
roleInfoByPubkey.set(pubkey, {
roles: sortedRoles,
primaryRole,
sortKey: primaryRole?.order ?? -Infinity,
})
}
return roleInfoByPubkey
})
export const roleColorToCSS = (hue: number) => `oklch(0.75 0.15 ${(hue * 360) / 255})`
+40 -48
View File
@@ -20,7 +20,6 @@ import {
partition,
shuffle,
parseJson,
memoize,
addToMapKey,
identity,
always,
@@ -84,7 +83,6 @@ import {
ROOM_JOIN,
ROOM_LEAVE,
ROOM_MEMBERS,
ROOM_ADMINS,
ROOM_META,
ROOM_DELETE,
ROOM_REMOVE_MEMBER,
@@ -152,6 +150,15 @@ import {
} from "@welshman/app"
import {checkRelayHasLivekit} from "$lib/livekit"
import {readFeed} from "@lib/feeds"
import {
parseRoomMembers,
deriveRoomMembers as deriveRoomMembersByRole,
deriveRoomAdmins as deriveRoomAdminsByRole,
deriveUserIsSpaceAdmin as deriveUserIsSpaceAdminByRole,
deriveUserIsRoomAdmin as deriveUserIsRoomAdminByRole,
deriveUserSpacePermissions,
} from "@app/core/roles"
import type {RoomMember} from "@app/core/roles"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@@ -813,13 +820,7 @@ export const deriveSpaceMembers = (url: string) =>
uniq(getTagValues("member", event?.tags ?? [])),
)
export const deriveRoomMembers = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}]
return derived(deriveEventsForUrl(url, filters), ([event]) =>
uniq(getPubkeyTagValues(event?.tags ?? [])),
)
}
export const deriveRoomMembers = deriveRoomMembersByRole
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Instead of re-exporting, update imports

Instead of re-exporting, update imports
export type BannedPubkeyItem = {
pubkey: string
@@ -839,19 +840,7 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => {
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)
}
return []
})
}
export const deriveRoomAdmins = deriveRoomAdminsByRole
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Instead of re-exporting, update imports

Instead of re-exporting, update imports
const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
const members = new Set<string>()
@@ -860,8 +849,8 @@ const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
if (event.kind === ROOM_MEMBERS && getTagValue("d", event.tags) === h) {
members.clear()
for (const pubkey of uniq(getPubkeyTagValues(event.tags))) {
members.add(pubkey)
for (const member of parseRoomMembers(event.tags)) {
members.add(member.pubkey)
}
continue
@@ -894,16 +883,30 @@ const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
export const deriveSpaceActionItems = (url: string) =>
derived(
deriveEventsForUrl(url, [
{
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
},
]),
$events => {
[
deriveEventsForUrl(url, [
{
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
},
]),
deriveUserIsSpaceAdmin(url),
deriveUserSpacePermissions(url),
],
([$events, $isAdmin, $permissions]) => {
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

This seems unnecessary to me, isn't this taken into account by deriveUserSpacePermissions?

This seems unnecessary to me, isn't this taken into account by `deriveUserSpacePermissions`?
if (!$isAdmin) {
return []
}
const getRoomId = (e: TrustedEvent) =>
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
const reports = $events.filter(e => e.kind === REPORT)
const pendingJoins: TrustedEvent[] = []
const canReviewReports = $permissions.has(9005) || $permissions.size === 0
const canReviewJoins =
$permissions.has(9000) ||
$permissions.has(9001) ||
$permissions.has(9009) ||
$permissions.size === 0
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

The permissions.size is here because this assumes permissions aren't filled for the space admin, but I think that is actually done in the roles file.

The permissions.size is here because this assumes permissions aren't filled for the space admin, but I think that is actually done in the roles file.
// Room-level join requests — most recent per pubkey+h
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
@@ -960,7 +963,10 @@ export const deriveSpaceActionItems = (url: string) =>
)
}
return sortEventsDesc([...reports, ...pendingJoins])
return sortEventsDesc([
...(canReviewReports ? reports : []),
...(canReviewJoins ? pendingJoins : []),
])
},
)
@@ -972,17 +978,7 @@ export enum MembershipStatus {
Granted,
}
export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
const store = writable(false)
if (url) {
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
store.set(Boolean(res.result?.length)),
)
}
return store
})
export const deriveUserIsSpaceAdmin = deriveUserIsSpaceAdminByRole
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Instead of re-exporting, update imports

Instead of re-exporting, update imports
export const deriveUserSpaceMembershipStatus = (url: string) => {
// Fetch member list and user add/remove events directly in this derivation.
@@ -1046,11 +1042,7 @@ export const deriveUserSpaceMembershipStatus = (url: string) => {
)
}
export const deriveUserIsRoomAdmin = (url: string, h: string) =>
derived(
[pubkey, deriveRoomAdmins(url, h), deriveUserIsSpaceAdmin(url)],
([$pubkey, $admins, $isSpaceAdmin]) => $isSpaceAdmin || $admins.includes($pubkey!),
)
export const deriveUserIsRoomAdmin = deriveUserIsRoomAdminByRole
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

Instead of re-exporting, update imports

Instead of re-exporting, update imports
export const deriveUserRoomMembershipStatus = (url: string, h: string) => {
// Fetch the room member list and the current user's add/remove events.
@@ -1075,7 +1067,7 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) => {
if ($memberList) {
// Member list exists - check if user is in it.
isMember = $memberList.includes($pubkey!)
isMember = $memberList.some((member: RoomMember) => member.pubkey === $pubkey)
} else {
// No member list available - replay the user's add/remove history.
for (const event of sortEventsAsc($userAddRemoveEvents)) {
+2 -1
View File
@@ -55,6 +55,7 @@ import {
makeCommentFilter,
loadFeedsForPubkey,
} from "@app/core/state"
import {ROOM_ROLES} from "@app/core/roles"
import {hasBlossomSupport} from "@app/core/commands"
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
@@ -271,7 +272,7 @@ const syncSpace = (url: string) => {
const since = ago(WEEK)
const controller = new AbortController()
const relayKinds = [RELAY_MEMBERS]
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomMetaKinds = [ROOM_META, ROOM_ROLES, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
pullAndListen({
+2
View File
@@ -47,6 +47,7 @@ import {
} from "@welshman/app"
import type {Unsubscriber} from "svelte/store"
import {db} from "@app/core/storage"
import {ROOM_ROLES} from "@app/core/roles"
// Shared interval for all non-critical store flushes, so they batch on the same cadence
const FLUSH_INTERVAL = 3000
@@ -65,6 +66,7 @@ const kinds = {
alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID],
space: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE],
room: [
ROOM_ROLES,
ROOM_META,
ROOM_DELETE,
ROOM_ADMINS,