Refactor role view models and member grouping

This commit is contained in:
2026-04-21 03:19:21 +05:30
parent 5a926ab6cf
commit 7259e4d2cf
6 changed files with 144 additions and 201 deletions
+5 -7
View File
@@ -33,7 +33,7 @@
import {pubkeyLink, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {
deriveUserHasSpacePermission,
deriveSpaceMemberRoleInfo,
deriveSpaceMemberRoles,
ROOM_PERMISSION_ADD_MEMBER,
ROOM_PERMISSION_BAN_USER,
} from "@app/core/roles"
@@ -59,9 +59,7 @@
const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined
const spaceMemberRoles = url ? deriveSpaceMemberRoleInfo(url) : readable(new Map())
const assignedRoles = $derived($spaceMemberRoles.get(pubkey)?.roles || [])
const assignedRoles = url ? deriveSpaceMemberRoles(url, pubkey) : readable([])
const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false)
@@ -163,12 +161,12 @@
{/if}
</div>
<ProfileInfo {pubkey} {url} />
{#if assignedRoles.length > 0}
{#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 $assignedRoles as role (role.name)}
<RoleBadge {role} class="badge-md" />
{/each}
</div>
</div>
+11 -4
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import cx from "classnames"
import type {RoleDefinition} from "@app/core/roles"
import {roleColorToCSS} from "@app/core/roles"
type Props = {
role: string
role: RoleDefinition | string
label?: string
color?: number
class?: string
@@ -11,15 +12,21 @@
const {role, label, color, ...props}: Props = $props()
const roleName = $derived(typeof role === "string" ? role : role.name)
const roleLabel = $derived(
label || (typeof role === "string" ? undefined : role.label) || roleName,
)
const roleColor = $derived(color ?? (typeof role === "string" ? undefined : role.color))
const style = $derived(
color === undefined
roleColor === undefined
? ""
: `color: ${roleColorToCSS(color)}; border-color: ${roleColorToCSS(color)};`,
: `color: ${roleColorToCSS(roleColor)}; border-color: ${roleColorToCSS(roleColor)};`,
)
const className = $derived(cx("badge badge-outline badge-sm", props.class))
</script>
<span class={className} {style}>
{label || role}
{roleLabel}
</span>
+11 -27
View File
@@ -34,10 +34,11 @@
import RoomImage from "@app/components/RoomImage.svelte"
import {
deriveRoomMembers,
deriveRoomRoles,
deriveRoomRoleDefinitions,
deriveUserIsRoomAdmin,
deriveHasPermission,
sortRolesDesc,
getRolePermissionsLabel,
getRoleAccessLabel,
ROOM_PERMISSION_EDIT_META,
} from "@app/core/roles"
import {
@@ -65,7 +66,7 @@
const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h)
const roomRoles = deriveRoomRoles(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)
@@ -74,19 +75,6 @@
const isFavorite = $derived($userRooms.includes(h))
const shouldNotify = deriveShouldNotify(url, h)
const roleRows = $derived.by(() =>
sortRolesDesc(
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()
const toggleMenu = () => {
@@ -278,27 +266,23 @@
<span class="text-error">Member list not available from this relay</span>
</div>
{/if}
{#if $userIsAdmin && roleRows.length > 0}
{#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 roleRows as role (role.name)}
{#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={role.name}
label={role.label}
color={role.color}
class="badge-md" />
<RoleBadge {role} class="badge-md" />
{#if role.order !== undefined}
<span class="text-xs opacity-70">Order {role.order}</span>
{/if}
</div>
{#if role.permissionsLabel}
<p class="text-xs opacity-75">Permissions: {role.permissionsLabel}</p>
{#if role.permissions.length > 0}
<p class="text-xs opacity-75">Permissions: {getRolePermissionsLabel(role)}</p>
{/if}
{#if role.accessLabel}
<p class="text-xs opacity-75">Access: {role.accessLabel}</p>
{#if role.access.size > 0}
<p class="text-xs opacity-75">Access: {getRoleAccessLabel(role)}</p>
{/if}
</div>
{/each}
+9 -68
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import {first, removeUndefined, 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"
@@ -20,12 +19,10 @@
import RoleBadge from "@app/components/RoleBadge.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomMembersAdd from "@app/components/RoomMembersAdd.svelte"
import type {RoomMember} from "@app/core/roles"
import {
deriveRoomMembers,
deriveRoomRoles,
deriveGroupedRoomMembers,
deriveHasPermission,
sortRolesDesc,
ROOM_PERMISSION_ADD_MEMBER,
ROOM_PERMISSION_REMOVE_MEMBER,
} from "@app/core/roles"
@@ -42,7 +39,7 @@
const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h)
const roomRoles = deriveRoomRoles(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)
@@ -56,58 +53,6 @@
menuPubkey = undefined
}
const getResolvedRoles = (member: RoomMember) =>
removeUndefined(member.roles.map(roleName => $roomRoles.roles.get(roleName)))
const getPrimaryRole = (member: RoomMember) => first(sortRolesDesc(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[],
}
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 addMember = () => pushModal(RoomMembersAdd, {url, h})
const removeMember = (pubkey: string) =>
@@ -147,16 +92,12 @@
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each memberGroups as group (group.key)}
{#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" />
{#if group.role}
<RoleBadge role={group.role} class="badge-md" />
{:else}
<span class="text-sm font-semibold opacity-75">{group.label}</span>
<span class="text-sm font-semibold opacity-75">Members</span>
{/if}
</div>
{#each group.members as member (member.pubkey)}
@@ -164,10 +105,10 @@
<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}
{#if member.roles.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 member.roles as role (role.name)}
<RoleBadge {role} />
{/each}
</div>
{/if}
+9 -81
View File
@@ -1,5 +1,4 @@
<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"
@@ -23,14 +22,13 @@
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,
deriveSupportedMethods,
} from "@app/core/state"
import {
deriveSpaceMemberRoleInfo,
deriveGroupedSpaceMembers,
deriveUserHasSpacePermission,
ROOM_PERMISSION_ADD_MEMBER,
ROOM_PERMISSION_REMOVE_MEMBER,
@@ -47,7 +45,7 @@
const members = deriveSpaceMembers(url)
const bans = deriveSpaceBannedPubkeyItems(url)
const spaceMemberRoles = deriveSpaceMemberRoleInfo(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)
@@ -59,72 +57,6 @@
$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
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)
}
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()
const toggleMenu = (pubkey: string) => {
@@ -203,16 +135,12 @@
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each memberGroups as group (group.key)}
{#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" />
{#if group.role}
<RoleBadge role={group.role} class="badge-md" />
{:else}
<span class="text-sm font-semibold opacity-75">{group.label}</span>
<span class="text-sm font-semibold opacity-75">Members</span>
{/if}
</div>
{#each group.members as member (member.pubkey)}
@@ -220,10 +148,10 @@
<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}
{#if member.roles.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 member.roles as role (role.name)}
<RoleBadge {role} />
{/each}
</div>
{/if}
+99 -14
View File
@@ -1,4 +1,4 @@
import {derived, readable} from "svelte/store"
import {derived, readable, type Readable} from "svelte/store"
import {first, memoize, removeUndefined, simpleCache, sortBy, uniq} from "@welshman/lib"
import {deriveArray, deriveEventsByIdForUrl} from "@welshman/store"
import {pubkey, repository, tracker} from "@welshman/app"
@@ -53,6 +53,18 @@ export type RoomMember = {
roles: string[]
}
export type MemberRoleInfo = {
pubkey: string
roles: RoleDefinition[]
primaryRole?: RoleDefinition
}
export type MemberRoleGroup = {
key: string
role?: RoleDefinition
members: MemberRoleInfo[]
}
type ParsedRoleState = {
roles: Map<string, RoleDefinition>
hasPermissionTags: boolean
@@ -65,11 +77,7 @@ type RoomSnapshot = {
admins: RoomMember[]
}
export type SpaceMemberRoleInfo = {
roles: RoleDefinition[]
primaryRole?: RoleDefinition
sortKey: number
}
export type SpaceMemberRoleInfo = MemberRoleInfo
type SpaceRoleState = {
hasPermissionTags: boolean
@@ -275,8 +283,62 @@ const getResolvedRoles = (rolesByName: Map<string, RoleDefinition>, roleNames: s
export const sortRolesDesc = <T extends {order?: number}>(items: T[]) =>
sortBy(item => -(item.order ?? -Infinity), items)
export const getRoleLabel = (role: RoleDefinition) => role.label || role.name
export const getRolePermissionsLabel = (role: RoleDefinition) => role.permissions.join(", ")
export const getRoleAccessLabel = (role: RoleDefinition) => Array.from(role.access).join(", ")
const getPrimaryRole = (roles: RoleDefinition[]) => first(sortRolesDesc(roles))
const toMemberRoleInfo = (pubkey: string, roles: RoleDefinition[]): MemberRoleInfo => {
const sortedRoles = sortRolesDesc(roles)
return {
pubkey,
roles: sortedRoles,
primaryRole: first(sortedRoles),
}
}
const sortMemberRoleInfos = (members: MemberRoleInfo[]) =>
sortBy(member => -(member.primaryRole?.order ?? -Infinity), members)
export 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)],
@@ -523,6 +585,23 @@ export const deriveUserRoleColor = (url: string, h: string, targetPubkey: string
getPrimaryRole(getResolvedRoles($roomRoles.roles, $roleNames))?.color,
)
export const deriveRoomRoleDefinitions = (url: string, h: string) =>
derived(deriveRoomRoles(url, h), $roomRoles =>
sortRolesDesc(Array.from($roomRoles.roles.values())),
)
export 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))
export const getRoleSortKey = (url: string, h: string, targetPubkey: string) =>
derived(
[deriveUserRoles(url, h, targetPubkey), deriveRoomRoles(url, h)],
@@ -535,17 +614,23 @@ export const deriveSpaceMemberRoleInfo = (url: string) =>
const roleInfoByPubkey = new Map<string, SpaceMemberRoleInfo>()
for (const [pubkey, roles] of $spaceRoleState.memberRoles.entries()) {
const sortedRoles = sortRolesDesc(roles)
const primaryRole = first(sortedRoles)
roleInfoByPubkey.set(pubkey, {
roles: sortedRoles,
primaryRole,
sortKey: primaryRole?.order ?? -Infinity,
})
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})`