feat(rbac): implement NIP-29 room roles and permission gating (#47) #220
@@ -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"
|
||||
@@ -17,7 +17,8 @@
|
||||
import Report from "@app/components/Report.svelte"
|
||||
import EventShare from "@app/components/EventShare.svelte"
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {hasNip29, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {deriveHasPermission, ROOM_PERMISSION_DELETE_EVENT} from "@app/core/roles"
|
||||
import {hasNip29} from "@app/core/state"
|
||||
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 = deriveHasPermission(url, h, ROOM_PERMISSION_DELETE_EVENT)
|
||||
|
||||
const report = () => pushModal(Report, {url, event})
|
||||
|
||||
@@ -107,7 +109,7 @@
|
||||
Report Content
|
||||
</Button>
|
||||
</li>
|
||||
{#if $userIsAdmin}
|
||||
{#if $canDelete}
|
||||
<li>
|
||||
<Button class="text-error" onclick={showAdminDelete}>
|
||||
<Icon size={4} icon={TrashBin2} />
|
||||
|
||||
@@ -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,14 @@
|
||||
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,
|
||||
deriveSpaceMemberRoles,
|
||||
ROOM_PERMISSION_ADD_MEMBER,
|
||||
ROOM_PERMISSION_BAN_USER,
|
||||
} from "@app/core/roles"
|
||||
import {addSpaceMembers} from "@app/core/commands"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
@@ -43,10 +51,16 @@
|
||||
|
||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
||||
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const canBan = url ? deriveUserHasSpacePermission(url, ROOM_PERMISSION_BAN_USER) : readable(false)
|
||||
|
||||
const canRestore = url
|
||||
? deriveUserHasSpacePermission(url, ROOM_PERMISSION_ADD_MEMBER)
|
||||
: readable(false)
|
||||
|
||||
const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined
|
||||
|
||||
const assignedRoles = url ? deriveSpaceMemberRoles(url, pubkey) : readable([])
|
||||
|
||||
const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false)
|
||||
|
||||
const back = () => history.back()
|
||||
@@ -105,7 +119,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 +137,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 +161,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} class="badge-md" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<ProfileBadges {pubkey} {url} />
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Reaction from "@app/components/Reaction.svelte"
|
||||
import ReportDetails from "@app/components/ReportDetails.svelte"
|
||||
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/core/roles"
|
||||
import {REACTION_KINDS} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/core/roles"
|
||||
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {RoleDefinition} from "@app/core/roles"
|
||||
import {roleColorToCSS} from "@app/core/roles"
|
||||
|
||||
type Props = {
|
||||
role: RoleDefinition
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {role, ...props}: Props = $props()
|
||||
|
||||
const style = $derived(
|
||||
role.color === undefined
|
||||
? ""
|
||||
: `color: ${roleColorToCSS(role.color)}; border-color: ${roleColorToCSS(role.color)};`,
|
||||
)
|
||||
|
||||
const className = $derived(cx("badge badge-outline badge-sm", props.class))
|
||||
</script>
|
||||
|
||||
<span class={className} {style}>
|
||||
{role.label || role.name}
|
||||
</span>
|
||||
@@ -27,14 +27,22 @@
|
||||
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 {
|
||||
deriveRoom,
|
||||
deriveRoomMembers,
|
||||
deriveRoomRoleDefinitions,
|
||||
deriveUserIsRoomAdmin,
|
||||
deriveHasPermission,
|
||||
getRolePermissionsLabel,
|
||||
getRoleAccessLabel,
|
||||
ROOM_PERMISSION_EDIT_META,
|
||||
} from "@app/core/roles"
|
||||
import {
|
||||
deriveRoom,
|
||||
deriveUserRoomMembershipStatus,
|
||||
deriveUserRooms,
|
||||
deriveShouldNotify,
|
||||
@@ -58,7 +66,9 @@
|
||||
|
||||
const room = deriveRoom(url, h)
|
||||
const members = deriveRoomMembers(url, h)
|
||||
const roleDefinitions = deriveRoomRoleDefinitions(url, h)
|
||||
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
|
||||
const canEditMetadata = deriveHasPermission(url, h, ROOM_PERMISSION_EDIT_META)
|
||||
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||
const userRooms = deriveUserRooms(url)
|
||||
|
||||
@@ -152,7 +162,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} />
|
||||
@@ -243,17 +253,36 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if $members !== undefined && $members.length > 0}
|
||||
{#if $members.length > 0}
|
||||
<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>
|
||||
{: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>
|
||||
{/if}
|
||||
{#if $userIsAdmin && $roleDefinitions.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 $roleDefinitions 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} class="badge-md" />
|
||||
{#if role.order !== undefined}
|
||||
<span class="text-xs opacity-70">Order {role.order}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if role.permissions.length > 0}
|
||||
<p class="text-xs opacity-75">Permissions: {getRolePermissionsLabel(role)}</p>
|
||||
{/if}
|
||||
{#if role.access.size > 0}
|
||||
<p class="text-xs opacity-75">Access: {getRoleAccessLabel(role)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card2 card2-sm bg-alt col-4">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/core/roles"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
||||
@@ -16,9 +16,17 @@
|
||||
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 {
|
||||
deriveRoomMembers,
|
||||
deriveGroupedRoomMembers,
|
||||
deriveHasPermission,
|
||||
ROOM_PERMISSION_ADD_MEMBER,
|
||||
ROOM_PERMISSION_REMOVE_MEMBER,
|
||||
} from "@app/core/roles"
|
||||
import {deriveRoom} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
@@ -31,7 +39,9 @@
|
||||
|
||||
const room = deriveRoom(url, h)
|
||||
const members = deriveRoomMembers(url, h)
|
||||
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
|
||||
const memberGroups = deriveGroupedRoomMembers(url, h)
|
||||
const canAddMembers = deriveHasPermission(url, h, ROOM_PERMISSION_ADD_MEMBER)
|
||||
const canRemoveMembers = deriveHasPermission(url, h, ROOM_PERMISSION_REMOVE_MEMBER)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
@@ -73,42 +83,58 @@
|
||||
</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if $members === undefined}
|
||||
<div class="card2 bg-base-200 p-4">
|
||||
<span class="text-error">Member list not available from this relay</span>
|
||||
</div>
|
||||
{:else if $members.length === 0}
|
||||
{#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 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.role}
|
||||
<RoleBadge role={group.role} class="badge-md" />
|
||||
{:else}
|
||||
<span class="text-sm font-semibold opacity-75">Members</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 member.roles.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each member.roles as role (role.name)}
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
|
||||
<RoleBadge {role} />
|
||||
{/each}
|
||||
</div>
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
This whole thing could probably go in the 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.
|
||||
{/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 +144,7 @@
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
{#if $userIsAdmin}
|
||||
{#if $canAddMembers}
|
||||
<Button class="btn btn-primary" onclick={addMember}>
|
||||
<Icon icon={AddCircle} />
|
||||
Add members
|
||||
|
||||
@@ -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, ROOM_PERMISSION_EDIT_META} 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, ROOM_PERMISSION_EDIT_META)
|
||||
|
||||
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
|
||||
|
||||
@@ -17,16 +17,20 @@
|
||||
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 {deriveSpaceMembers, deriveSpaceBannedPubkeyItems} from "@app/core/state"
|
||||
import {
|
||||
deriveSpaceMembers,
|
||||
deriveSpaceBannedPubkeyItems,
|
||||
deriveUserIsSpaceAdmin,
|
||||
deriveGroupedSpaceMembers,
|
||||
deriveSupportedMethods,
|
||||
} from "@app/core/state"
|
||||
deriveUserHasSpacePermission,
|
||||
ROOM_PERMISSION_ADD_MEMBER,
|
||||
ROOM_PERMISSION_REMOVE_MEMBER,
|
||||
ROOM_PERMISSION_BAN_USER,
|
||||
} from "@app/core/roles"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
@@ -38,10 +42,17 @@
|
||||
|
||||
const members = deriveSpaceMembers(url)
|
||||
const bans = deriveSpaceBannedPubkeyItems(url)
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const memberGroups = deriveGroupedSpaceMembers(url, members)
|
||||
const canAddMember = deriveUserHasSpacePermission(url, ROOM_PERMISSION_ADD_MEMBER)
|
||||
const canBanByPermission = deriveUserHasSpacePermission(url, ROOM_PERMISSION_BAN_USER)
|
||||
const canUnallowByPermission = deriveUserHasSpacePermission(url, ROOM_PERMISSION_REMOVE_MEMBER)
|
||||
|
hodlbod
commented
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 supportedMethods = deriveSupportedMethods(url)
|
||||
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),
|
||||
)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
@@ -104,7 +115,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})
|
||||
@@ -112,56 +123,68 @@
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if $members === undefined}
|
||||
<div class="card2 bg-base-200 p-4">
|
||||
<span class="text-error">Member list not available from this space</span>
|
||||
</div>
|
||||
{:else if $members.length === 0}
|
||||
{#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>
|
||||
{#each $memberGroups as group (group.key)}
|
||||
<div class="pt-2 pb-1">
|
||||
{#if group.role}
|
||||
<RoleBadge role={group.role} class="badge-md" />
|
||||
{:else}
|
||||
<span class="text-sm font-semibold opacity-75">Members</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.roles.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each member.roles as role (role.name)}
|
||||
<RoleBadge {role} />
|
||||
{/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 +194,7 @@
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
{#if $userIsAdmin}
|
||||
{#if $canAddMember}
|
||||
<Button class="btn btn-primary" onclick={addMember}>
|
||||
<Icon icon={AddCircle} />
|
||||
Add members
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state"
|
||||
import {deriveSupportedMethods} from "@app/core/roles"
|
||||
import {deriveSpaceBannedPubkeyItems} from "@app/core/state"
|
||||
import {addSpaceMembers} from "@app/core/commands"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/core/roles"
|
||||
import {
|
||||
ENABLE_ZAPS,
|
||||
CONTENT_KINDS,
|
||||
@@ -50,7 +51,6 @@
|
||||
userSpaceUrls,
|
||||
hasNip29,
|
||||
deriveUserCanCreateRoom,
|
||||
deriveUserIsSpaceAdmin,
|
||||
deriveEventsForUrl,
|
||||
deriveSpaceActionItems,
|
||||
notificationSettings,
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import {derived, readable, type Readable} from "svelte/store"
|
||||
import {first, memoize, removeUndefined, simpleCache, sortBy, uniq} from "@welshman/lib"
|
||||
import {pubkey, manageRelay} from "@welshman/app"
|
||||
import {deriveEventsForUrl} from "@app/core/state"
|
||||
import {
|
||||
ManagementMethod,
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOM_EDIT_META,
|
||||
ROOM_DELETE_EVENT,
|
||||
ROOM_ADMINS,
|
||||
ROOM_MEMBERS,
|
||||
getTagValue,
|
||||
isRelayUrl,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
|
||||
export const ROOM_ROLES = 39003
|
||||
|
||||
export const ROOM_PERMISSION_ADD_MEMBER = ROOM_ADD_MEMBER
|
||||
export const ROOM_PERMISSION_REMOVE_MEMBER = ROOM_REMOVE_MEMBER
|
||||
export const ROOM_PERMISSION_EDIT_META = ROOM_EDIT_META
|
||||
export const ROOM_PERMISSION_DELETE_EVENT = ROOM_DELETE_EVENT
|
||||
export const ROOM_PERMISSION_BAN_USER = 9009
|
||||
|
||||
const ALL_ROOM_PERMISSIONS = [
|
||||
ROOM_PERMISSION_ADD_MEMBER,
|
||||
ROOM_PERMISSION_REMOVE_MEMBER,
|
||||
ROOM_PERMISSION_EDIT_META,
|
||||
ROOM_PERMISSION_DELETE_EVENT,
|
||||
ROOM_PERMISSION_BAN_USER,
|
||||
]
|
||||
|
||||
export const deriveSupportedMethods = simpleCache(([url]: [string]) =>
|
||||
readable<ManagementMethod[]>([], set => {
|
||||
manageRelay(url, {
|
||||
method: ManagementMethod.SupportedMethods,
|
||||
params: [],
|
||||
}).then(({result = []}) => set(result))
|
||||
}),
|
||||
)
|
||||
|
||||
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 MemberRoleInfo = {
|
||||
pubkey: string
|
||||
roles: RoleDefinition[]
|
||||
primaryRole?: RoleDefinition
|
||||
}
|
||||
|
||||
type MemberRoleGroup = {
|
||||
key: string
|
||||
role?: RoleDefinition
|
||||
members: MemberRoleInfo[]
|
||||
}
|
||||
|
||||
type ParsedRoleState = {
|
||||
roles: Map<string, RoleDefinition>
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
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.
Khushvendra
commented
ill have a look into this (less code = always better) ill have a look into this (less code = always better)
|
||||
hasPermissionTags: boolean
|
||||
}
|
||||
|
||||
type SpaceRoleState = {
|
||||
hasPermissionTags: boolean
|
||||
userPermissions: Set<number>
|
||||
memberRoles: Map<string, RoleDefinition[]>
|
||||
}
|
||||
|
||||
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))
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
This function exists already in core/state This function exists already in core/state
|
||||
}
|
||||
|
||||
return roles.get(roleName)!
|
||||
}
|
||||
|
||||
const asNumber = (value: string | undefined) => {
|
||||
const n = parseInt(value || "")
|
||||
|
||||
return isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
const asAccess = (value: string | undefined): RoleAccess | undefined => {
|
||||
if (value === "read" || value === "write" || value === "join") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const parseRoleState = (event?: TrustedEvent): ParsedRoleState => {
|
||||
const roles = new Map<string, RoleDefinition>()
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
export const deriveRoomMembers = (url: string, h: string) =>
|
||||
derived(deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS], "#d": [h]}]), ([event]) =>
|
||||
parseRoomMembers(event?.tags || []),
|
||||
)
|
||||
|
||||
export const deriveRoomAdmins = (url: string, h: string) =>
|
||||
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [h]}]), ([event]) =>
|
||||
parseRoomMembers(event?.tags || []),
|
||||
)
|
||||
|
||||
const deriveRoomRoleState = simpleCache(([url, h]: [string, string]) =>
|
||||
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ROLES], "#d": [h]}]), ([event]) =>
|
||||
parseRoleState(event),
|
||||
),
|
||||
)
|
||||
|
||||
export const deriveRoomRoles = (url: string, h: string) =>
|
||||
derived(deriveRoomRoleState(url, h), $state => ({
|
||||
url,
|
||||
h,
|
||||
roles: $state.roles,
|
||||
}))
|
||||
|
||||
const getMember = (members: RoomMember[], targetPubkey: string) =>
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
This could be simplified to 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.
|
||||
members.find(member => member.pubkey === targetPubkey)
|
||||
|
||||
const getResolvedRoles = (rolesByName: Map<string, RoleDefinition>, roleNames: string[]) =>
|
||||
removeUndefined(roleNames.map(name => rolesByName.get(name)))
|
||||
|
||||
export const sortRolesDesc = <T extends {order?: number}>(items: T[]) =>
|
||||
sortBy(item => -(item.order ?? -Infinity), items)
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
Same thing here, which means you can remove Same thing here, which means you can remove `deriveRoomListStore`
|
||||
|
||||
export const getRolePermissionsLabel = (role: RoleDefinition) => role.permissions.join(", ")
|
||||
|
||||
export const getRoleAccessLabel = (role: RoleDefinition) => Array.from(role.access).join(", ")
|
||||
|
||||
const toMemberRoleInfo = (pubkey: string, roles: RoleDefinition[]): MemberRoleInfo => {
|
||||
const sortedRoles = sortRolesDesc(roles)
|
||||
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
Same here, no need for getLatest, just use the zero or one event provided. That means you can remove Same here, no need for getLatest, just use the zero or one event provided. That means you can remove `getLatestEventByKind` too
|
||||
return {
|
||||
pubkey,
|
||||
roles: sortedRoles,
|
||||
primaryRole: first(sortedRoles),
|
||||
}
|
||||
}
|
||||
|
||||
const sortMemberRoleInfos = (members: MemberRoleInfo[]) =>
|
||||
sortBy(member => -(member.primaryRole?.order ?? -Infinity), members)
|
||||
|
||||
const groupMemberRoleInfos = (members: MemberRoleInfo[]) => {
|
||||
const byRole = new Map<string, MemberRoleGroup>()
|
||||
const ungrouped: MemberRoleGroup = {
|
||||
key: "members",
|
||||
members: [],
|
||||
}
|
||||
|
||||
for (const member of sortMemberRoleInfos(members)) {
|
||||
if (!member.primaryRole) {
|
||||
ungrouped.members.push(member)
|
||||
continue
|
||||
}
|
||||
|
||||
const key = member.primaryRole.name
|
||||
|
||||
if (!byRole.has(key)) {
|
||||
byRole.set(key, {
|
||||
key,
|
||||
role: member.primaryRole,
|
||||
members: [],
|
||||
})
|
||||
}
|
||||
|
||||
byRole.get(key)!.members.push(member)
|
||||
}
|
||||
|
||||
const groups = sortBy(group => -(group.role?.order ?? -Infinity), Array.from(byRole.values()))
|
||||
|
||||
if (ungrouped.members.length > 0) {
|
||||
groups.push(ungrouped)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const deriveRoomRoleAssignments = simpleCache(([url, h]: [string, string]) =>
|
||||
derived(
|
||||
[deriveRoomRoleState(url, h), deriveRoomMembers(url, h), deriveRoomAdmins(url, h)],
|
||||
([$rolesState, $members, $admins]) => ({
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
This is redundant with This is redundant with `deriveSupportedMethods` in core/state.
|
||||
rolesState: $rolesState,
|
||||
members: $members,
|
||||
admins: $admins,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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 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 rolesByH = new Map<string, ReturnType<typeof parseRoleState>>()
|
||||
let hasPermissionTags = false
|
||||
|
||||
for (const event of $events) {
|
||||
if (event.kind === ROOM_ROLES) {
|
||||
const h = getTagValue("d", event.tags)
|
||||
if (h) {
|
||||
const parsed = parseRoleState(event)
|
||||
rolesByH.set(h, parsed)
|
||||
if (parsed.hasPermissionTags) {
|
||||
hasPermissionTags = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of $events) {
|
||||
if (event.kind === ROOM_MEMBERS || event.kind === ROOM_ADMINS) {
|
||||
const h = getTagValue("d", event.tags)
|
||||
if (!h) continue
|
||||
|
||||
const rolesState = rolesByH.get(h)
|
||||
if (!rolesState) continue
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
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.
|
||||
|
||||
const members = parseRoomMembers(event.tags)
|
||||
for (const member of members) {
|
||||
const resolvedRoles = getResolvedRoles(rolesState.roles, member.roles)
|
||||
|
||||
if (resolvedRoles.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
memberRoles.set(
|
||||
member.pubkey,
|
||||
mergeRoleDefinitions(memberRoles.get(member.pubkey) || [], resolvedRoles),
|
||||
)
|
||||
|
||||
if ($pubkey === member.pubkey && rolesState.hasPermissionTags) {
|
||||
for (const role of resolvedRoles) {
|
||||
for (const permission of role.permissions) {
|
||||
userPermissions.add(permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasPermissionTags,
|
||||
userPermissions,
|
||||
memberRoles,
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
|
||||
if (!url) {
|
||||
return readable(false)
|
||||
}
|
||||
|
||||
return derived(
|
||||
[deriveSpaceRoleState(url), deriveSupportedMethods(url)],
|
||||
([$spaceRoleState, $supportedMethods]) => {
|
||||
if ($spaceRoleState.hasPermissionTags) {
|
||||
return $spaceRoleState.userPermissions.size > 0
|
||||
}
|
||||
|
||||
return $supportedMethods.length > 0
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
should be deriveHasPermission should be deriveHasPermission
|
||||
}
|
||||
|
||||
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 deriveHasPermission = (url: string, h: string | undefined, kind: number) => {
|
||||
if (!h) return deriveUserIsSpaceAdmin(url)
|
||||
|
||||
return derived(
|
||||
[deriveUserPermissions(url, h), deriveUserIsSpaceAdmin(url)],
|
||||
([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.has(kind),
|
||||
)
|
||||
}
|
||||
|
||||
export const deriveRoomRoleDefinitions = (url: string, h: string) =>
|
||||
derived(deriveRoomRoles(url, h), $roomRoles =>
|
||||
sortRolesDesc(Array.from($roomRoles.roles.values())),
|
||||
)
|
||||
|
||||
const deriveRoomMemberRoleInfo = (url: string, h: string) =>
|
||||
derived([deriveRoomMembers(url, h), deriveRoomRoles(url, h)], ([$members, $roomRoles]) =>
|
||||
sortMemberRoleInfos(
|
||||
$members.map(member =>
|
||||
toMemberRoleInfo(member.pubkey, getResolvedRoles($roomRoles.roles, member.roles)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const deriveGroupedRoomMembers = (url: string, h: string) =>
|
||||
derived(deriveRoomMemberRoleInfo(url, h), $members => groupMemberRoleInfos($members))
|
||||
|
||||
const deriveSpaceMemberRoleInfo = (url: string) =>
|
||||
derived(deriveSpaceRoleState(url), $spaceRoleState => {
|
||||
const roleInfoByPubkey = new Map<string, MemberRoleInfo>()
|
||||
|
||||
for (const [pubkey, roles] of $spaceRoleState.memberRoles.entries()) {
|
||||
roleInfoByPubkey.set(pubkey, toMemberRoleInfo(pubkey, roles))
|
||||
}
|
||||
|
||||
return roleInfoByPubkey
|
||||
})
|
||||
|
||||
export const deriveSpaceMemberRoles = (url: string, targetPubkey: string) =>
|
||||
derived(
|
||||
deriveSpaceMemberRoleInfo(url),
|
||||
$spaceMemberRoles => $spaceMemberRoles.get(targetPubkey)?.roles || [],
|
||||
)
|
||||
|
||||
export const deriveGroupedSpaceMembers = (url: string, members: Readable<string[]>) =>
|
||||
derived([members, deriveSpaceMemberRoleInfo(url)], ([$members, $spaceMemberRoles]) =>
|
||||
groupMemberRoleInfos(
|
||||
$members.map(pubkey => $spaceMemberRoles.get(pubkey) || toMemberRoleInfo(pubkey, [])),
|
||||
),
|
||||
)
|
||||
|
||||
export const roleColorToCSS = (hue: number) => `oklch(0.75 0.15 ${(hue * 360) / 255})`
|
||||
@@ -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,18 @@ import {
|
||||
} from "@welshman/app"
|
||||
import {checkRelayHasLivekit} from "$lib/livekit"
|
||||
import {readFeed} from "@lib/feeds"
|
||||
import {
|
||||
parseRoomMembers,
|
||||
deriveRoomMembers,
|
||||
deriveUserIsSpaceAdmin,
|
||||
deriveUserIsRoomAdmin,
|
||||
deriveUserSpacePermissions,
|
||||
ROOM_PERMISSION_ADD_MEMBER,
|
||||
ROOM_PERMISSION_REMOVE_MEMBER,
|
||||
ROOM_PERMISSION_DELETE_EVENT,
|
||||
ROOM_PERMISSION_BAN_USER,
|
||||
} from "@app/core/roles"
|
||||
import type {RoomMember} from "@app/core/roles"
|
||||
|
||||
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
||||
|
||||
@@ -813,14 +823,6 @@ export const deriveSpaceMembers = (url: string) =>
|
||||
uniq(getTagValues("member", event?.tags ?? [])),
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
Instead of re-exporting, update imports Instead of re-exporting, update imports
|
||||
)
|
||||
|
||||
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 type BannedPubkeyItem = {
|
||||
pubkey: string
|
||||
reason: string
|
||||
@@ -839,20 +841,6 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => {
|
||||
return store
|
||||
}
|
||||
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
Instead of re-exporting, update imports Instead of re-exporting, update imports
|
||||
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 []
|
||||
})
|
||||
}
|
||||
|
||||
const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
|
||||
const members = new Set<string>()
|
||||
|
||||
@@ -860,8 +848,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 +882,31 @@ 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]) => {
|
||||
if (!$isAdmin) {
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
This seems unnecessary to me, isn't this taken into account by This seems unnecessary to me, isn't this taken into account by `deriveUserSpacePermissions`?
|
||||
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(ROOM_PERMISSION_DELETE_EVENT) || $permissions.size === 0
|
||||
const canReviewJoins =
|
||||
$permissions.has(ROOM_PERMISSION_ADD_MEMBER) ||
|
||||
$permissions.has(ROOM_PERMISSION_REMOVE_MEMBER) ||
|
||||
$permissions.has(ROOM_PERMISSION_BAN_USER) ||
|
||||
$permissions.size === 0
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
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,18 +978,6 @@ 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 deriveUserSpaceMembershipStatus = (url: string) => {
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
Instead of re-exporting, update imports Instead of re-exporting, update imports
|
||||
// Fetch member list and user add/remove events directly in this derivation.
|
||||
const memberListFilters: Filter[] = [{kinds: [RELAY_MEMBERS]}]
|
||||
@@ -1046,12 +1040,6 @@ 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 deriveUserRoomMembershipStatus = (url: string, h: string) => {
|
||||
// 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]}]
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
Instead of re-exporting, update imports Instead of re-exporting, update imports
|
||||
@@ -1075,7 +1063,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)) {
|
||||
@@ -1312,15 +1300,6 @@ export const deriveSocketStatus = (url: string) =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const deriveSupportedMethods = simpleCache(([url]: [string]) => {
|
||||
return readable<ManagementMethod[]>([], set => {
|
||||
manageRelay(url, {
|
||||
method: ManagementMethod.SupportedMethods,
|
||||
params: [],
|
||||
}).then(({result = []}) => set(result))
|
||||
})
|
||||
})
|
||||
|
||||
export const deriveHasLivekit = simpleCache(([url]: [string]) =>
|
||||
readable<boolean | undefined>(undefined, set => {
|
||||
checkRelayHasLivekit(url).then(has => set(has))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
This exists already in @welshman/lib