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, manageRelay} from "@welshman/app" 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 {Filter, 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([], 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 } export type RoomRoles = { url: string h: string roles: Map } 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 hasPermissionTags: boolean } type RoomSnapshot = { h: string rolesState: ParsedRoleState members: RoomMember[] admins: RoomMember[] } type SpaceRoleState = { hasPermissionTags: boolean userPermissions: Set memberRoles: Map } const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) => deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters})) const makeRoleDefinition = (name: string): RoleDefinition => ({ name, permissions: [], access: new Set(), }) const ensureRole = (roles: Map, 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 } 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() 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>() for (const tag of tags) { if (tag[0] !== "p" || !tag[1]) { continue } if (!byPubkey.has(tag[1])) { byPubkey.set(tag[1], new Set()) } 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, roleNames: string[]) => removeUndefined(roleNames.map(name => rolesByName.get(name))) export const sortRolesDesc = (items: T[]) => sortBy(item => -(item.order ?? -Infinity), items) 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) 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() 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]) => ({ 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() 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 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() 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() const memberRoles = new Map() 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) } } } } } 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() 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 deriveHasPermission = (url: string, h: string, kind: number) => 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() 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) => 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})`