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 } export type RoomRoles = { url: string h: string roles: Map } export type RoomMember = { pubkey: string roles: string[] } type ParsedRoleState = { roles: Map 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 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))) const getPrimaryRole = (roles: RoleDefinition[]) => first(sortBy(role => -(role.order ?? -Infinity), roles)) const removeUndefined = (items: Array): T[] => items.filter((item): item is T => item !== undefined) 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 deriveNip86SpaceAdmin = simpleCache(([url]: [string]) => readable(false, set => { manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}) .then(({result = []}) => { set(Boolean(result.length)) }) .catch(() => { set(false) }) }), ) 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), 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() 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) => 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() 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})`