forked from coracle/flotilla
577 lines
15 KiB
TypeScript
577 lines
15 KiB
TypeScript
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>
|
|
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))
|
|
}
|
|
|
|
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) =>
|
|
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)
|
|
|
|
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<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]) => ({
|
|
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
|
|
|
|
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
|
|
}
|
|
|
|
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})`
|