forked from coracle/flotilla
543 lines
14 KiB
TypeScript
543 lines
14 KiB
TypeScript
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<RoleAccess>
|
|
}
|
|
|
|
export type RoomRoles = {
|
|
url: string
|
|
h: string
|
|
roles: Map<string, RoleDefinition>
|
|
}
|
|
|
|
export type RoomMember = {
|
|
pubkey: string
|
|
roles: string[]
|
|
}
|
|
|
|
type ParsedRoleState = {
|
|
roles: Map<string, RoleDefinition>
|
|
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<number>
|
|
memberRoles: Map<string, RoleDefinition[]>
|
|
}
|
|
|
|
const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
|
|
deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
|
|
|
|
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 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<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),
|
|
}))
|
|
}
|
|
|
|
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<string, RoleDefinition>, roleNames: string[]) =>
|
|
removeUndefined(roleNames.map(name => rolesByName.get(name)))
|
|
|
|
const getPrimaryRole = (roles: RoleDefinition[]) =>
|
|
first(sortBy(role => -(role.order ?? -Infinity), roles))
|
|
|
|
const removeUndefined = <T>(items: Array<T | undefined>): 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<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 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<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 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<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 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<string, SpaceMemberRoleInfo>()
|
|
|
|
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})`
|