mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2026-10-05 14:38:23 +00:00
update nip29 helpers to latest spec.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nostr/tools",
|
"name": "@nostr/tools",
|
||||||
"version": "2.24.1",
|
"version": "2.24.2",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
"./core": "./core.ts",
|
"./core": "./core.ts",
|
||||||
|
|||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { Event } from './core.ts'
|
||||||
|
import {
|
||||||
|
Group,
|
||||||
|
GroupAdmin,
|
||||||
|
GroupAdminPermission,
|
||||||
|
GroupMember,
|
||||||
|
GroupRole,
|
||||||
|
GroupPinnedEvent,
|
||||||
|
generateEditGroupMetadataEventTemplate,
|
||||||
|
generateGroupAdminsEventTemplate,
|
||||||
|
generateGroupJoinRequestEventTemplate,
|
||||||
|
generateGroupLeaveRequestEventTemplate,
|
||||||
|
generateGroupLivekitParticipantsEventTemplate,
|
||||||
|
generateGroupMembersEventTemplate,
|
||||||
|
generateGroupMetadataEventTemplate,
|
||||||
|
generateGroupPinnedEventsEventTemplate,
|
||||||
|
generateGroupRolesEventTemplate,
|
||||||
|
generateCreateGroupEventTemplate,
|
||||||
|
generateCreateInviteEventTemplate,
|
||||||
|
generateDeleteEventEventTemplate,
|
||||||
|
generateDeleteGroupEventTemplate,
|
||||||
|
generatePutUserEventTemplate,
|
||||||
|
generateRemoveUserEventTemplate,
|
||||||
|
generateUpdatePinListEventTemplate,
|
||||||
|
parseGroupAdminsEvent,
|
||||||
|
parseGroupLivekitParticipantsEvent,
|
||||||
|
parseGroupMembersEvent,
|
||||||
|
parseGroupMetadataEvent,
|
||||||
|
parseGroupPinnedEventsEvent,
|
||||||
|
parseGroupRolesEvent,
|
||||||
|
validateGroupAdminsEvent,
|
||||||
|
validateGroupLivekitParticipantsEvent,
|
||||||
|
validateGroupMembersEvent,
|
||||||
|
validateGroupMetadataEvent,
|
||||||
|
validateGroupPinnedEventsEvent,
|
||||||
|
validateGroupRolesEvent,
|
||||||
|
} from './nip29.ts'
|
||||||
|
|
||||||
|
const group: Group = {
|
||||||
|
relay: 'wss://relay.example.com',
|
||||||
|
reference: {
|
||||||
|
id: 'sample-group-id',
|
||||||
|
host: 'wss://relay.example.com',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
id: 'sample-group-id',
|
||||||
|
pubkey: 'sample-pubkey',
|
||||||
|
name: 'Pizza Lovers',
|
||||||
|
banner: 'https://pizza.com/banner.png',
|
||||||
|
picture: 'https://pizza.com/pizza.png',
|
||||||
|
about: 'a group for people who love pizza',
|
||||||
|
isPrivate: true,
|
||||||
|
isRestricted: true,
|
||||||
|
isHidden: false,
|
||||||
|
isClosed: false,
|
||||||
|
hasLiveKit: true,
|
||||||
|
supportedKinds: ['9', '11'],
|
||||||
|
},
|
||||||
|
members: [],
|
||||||
|
admins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeEvent = (template: ReturnType<typeof generateGroupMetadataEventTemplate>): Event => ({
|
||||||
|
id: 'sample-id',
|
||||||
|
pubkey: 'sample-pubkey',
|
||||||
|
created_at: template.created_at,
|
||||||
|
kind: template.kind,
|
||||||
|
tags: template.tags,
|
||||||
|
content: template.content,
|
||||||
|
sig: 'sample-sig',
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NIP-29 group metadata event', () => {
|
||||||
|
test('generateGroupMetadataEventTemplate emits all metadata fields', () => {
|
||||||
|
const template = generateGroupMetadataEventTemplate(group)
|
||||||
|
expect(template.kind).toBe(39000)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['d', 'sample-group-id'],
|
||||||
|
['name', 'Pizza Lovers'],
|
||||||
|
['picture', 'https://pizza.com/pizza.png'],
|
||||||
|
['banner', 'https://pizza.com/banner.png'],
|
||||||
|
['about', 'a group for people who love pizza'],
|
||||||
|
['private'],
|
||||||
|
['restricted'],
|
||||||
|
['livekit'],
|
||||||
|
['supported_kinds', '9', '11'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseGroupMetadataEvent round-trips the metadata fields', () => {
|
||||||
|
const metadata = parseGroupMetadataEvent(makeEvent(generateGroupMetadataEventTemplate(group)))
|
||||||
|
expect(metadata).toEqual({
|
||||||
|
id: 'sample-group-id',
|
||||||
|
pubkey: 'sample-pubkey',
|
||||||
|
name: 'Pizza Lovers',
|
||||||
|
banner: 'https://pizza.com/banner.png',
|
||||||
|
picture: 'https://pizza.com/pizza.png',
|
||||||
|
about: 'a group for people who love pizza',
|
||||||
|
isPrivate: true,
|
||||||
|
isRestricted: true,
|
||||||
|
hasLiveKit: true,
|
||||||
|
supportedKinds: ['9', '11'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseGroupMetadataEvent parses subgroups parent and child tags', () => {
|
||||||
|
const event = makeEvent(generateGroupMetadataEventTemplate(group))
|
||||||
|
event.tags.push(['parent', 'tech'])
|
||||||
|
event.tags.push(['child', 'nostr'])
|
||||||
|
const metadata = parseGroupMetadataEvent(event)
|
||||||
|
expect(metadata.parent).toBe('tech')
|
||||||
|
expect(metadata.children).toEqual(['nostr'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateGroupMetadataEvent rejects events without a d tag', () => {
|
||||||
|
const event = makeEvent(generateGroupMetadataEventTemplate(group))
|
||||||
|
event.tags = []
|
||||||
|
expect(validateGroupMetadataEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NIP-29 group roles event', () => {
|
||||||
|
const roles: GroupRole[] = [{ name: 'ceo', description: 'runs the place' }, { name: 'secretary' }]
|
||||||
|
|
||||||
|
test('generateGroupRolesEventTemplate emits role tags', () => {
|
||||||
|
const template = generateGroupRolesEventTemplate(group, roles)
|
||||||
|
expect(template.kind).toBe(39003)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['d', 'sample-group-id'],
|
||||||
|
['role', 'ceo', 'runs the place'],
|
||||||
|
['role', 'secretary'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseGroupRolesEvent round-trips roles', () => {
|
||||||
|
const event = makeEvent(generateGroupRolesEventTemplate(group, roles))
|
||||||
|
expect(validateGroupRolesEvent(event)).toBe(true)
|
||||||
|
expect(parseGroupRolesEvent(event)).toEqual(roles)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NIP-29 group livekit participants event', () => {
|
||||||
|
test('generateGroupLivekitParticipantsEventTemplate emits participant tags', () => {
|
||||||
|
const template = generateGroupLivekitParticipantsEventTemplate(group, ['abc', 'def'])
|
||||||
|
expect(template.kind).toBe(39004)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['d', 'sample-group-id'],
|
||||||
|
['participant', 'abc'],
|
||||||
|
['participant', 'def'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseGroupLivekitParticipantsEvent round-trips participants', () => {
|
||||||
|
const event = makeEvent(generateGroupLivekitParticipantsEventTemplate(group, ['abc', 'def']))
|
||||||
|
expect(validateGroupLivekitParticipantsEvent(event)).toBe(true)
|
||||||
|
expect(parseGroupLivekitParticipantsEvent(event)).toEqual(['abc', 'def'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NIP-29 group pinned events event', () => {
|
||||||
|
const pinnedEvents: GroupPinnedEvent[] = [
|
||||||
|
{ type: 'e', value: 'event-id-1' },
|
||||||
|
{ type: 'a', value: '1:pubkey:d-identifier' },
|
||||||
|
{ type: 'e', value: 'event-id-2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('generateGroupPinnedEventsEventTemplate emits e and a tags in order', () => {
|
||||||
|
const template = generateGroupPinnedEventsEventTemplate(group, pinnedEvents)
|
||||||
|
expect(template.kind).toBe(39005)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['d', 'sample-group-id'],
|
||||||
|
['e', 'event-id-1'],
|
||||||
|
['a', '1:pubkey:d-identifier'],
|
||||||
|
['e', 'event-id-2'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseGroupPinnedEventsEvent round-trips pinned events in order', () => {
|
||||||
|
const event = makeEvent(generateGroupPinnedEventsEventTemplate(group, pinnedEvents))
|
||||||
|
expect(validateGroupPinnedEventsEvent(event)).toBe(true)
|
||||||
|
expect(parseGroupPinnedEventsEvent(event)).toEqual(pinnedEvents)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NIP-29 moderation events', () => {
|
||||||
|
const previous = ['eb96c864', '2db75638']
|
||||||
|
|
||||||
|
test('generatePutUserEventTemplate', () => {
|
||||||
|
const template = generatePutUserEventTemplate('sample-group-id', 'pubkey', ['ceo'], 'why', previous)
|
||||||
|
expect(template.kind).toBe(9000)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['h', 'sample-group-id'],
|
||||||
|
['p', 'pubkey', 'ceo'],
|
||||||
|
['previous', 'eb96c864', '2db75638'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateRemoveUserEventTemplate', () => {
|
||||||
|
const template = generateRemoveUserEventTemplate('sample-group-id', 'pubkey', 'bye')
|
||||||
|
expect(template.kind).toBe(9001)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['h', 'sample-group-id'],
|
||||||
|
['p', 'pubkey'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateEditGroupMetadataEventTemplate', () => {
|
||||||
|
const template = generateEditGroupMetadataEventTemplate(group)
|
||||||
|
expect(template.kind).toBe(9002)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['h', 'sample-group-id'],
|
||||||
|
['name', 'Pizza Lovers'],
|
||||||
|
['picture', 'https://pizza.com/pizza.png'],
|
||||||
|
['banner', 'https://pizza.com/banner.png'],
|
||||||
|
['about', 'a group for people who love pizza'],
|
||||||
|
['private'],
|
||||||
|
['restricted'],
|
||||||
|
['livekit'],
|
||||||
|
['supported_kinds', '9', '11'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateDeleteEventEventTemplate', () => {
|
||||||
|
const template = generateDeleteEventEventTemplate('sample-group-id', 'event-id', 'spam')
|
||||||
|
expect(template.kind).toBe(9005)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['h', 'sample-group-id'],
|
||||||
|
['e', 'event-id'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateCreateGroupEventTemplate', () => {
|
||||||
|
const template = generateCreateGroupEventTemplate('sample-group-id')
|
||||||
|
expect(template.kind).toBe(9007)
|
||||||
|
expect(template.tags).toEqual([['h', 'sample-group-id']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateDeleteGroupEventTemplate', () => {
|
||||||
|
const template = generateDeleteGroupEventTemplate('sample-group-id')
|
||||||
|
expect(template.kind).toBe(9008)
|
||||||
|
expect(template.tags).toEqual([['h', 'sample-group-id']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateCreateInviteEventTemplate', () => {
|
||||||
|
const template = generateCreateInviteEventTemplate('sample-group-id', 'invite-code')
|
||||||
|
expect(template.kind).toBe(9009)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['h', 'sample-group-id'],
|
||||||
|
['code', 'invite-code'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateUpdatePinListEventTemplate', () => {
|
||||||
|
const template = generateUpdatePinListEventTemplate('sample-group-id', [
|
||||||
|
{ type: 'e', value: 'event-id-1' },
|
||||||
|
{ type: 'a', value: '1:pubkey:d-identifier' },
|
||||||
|
])
|
||||||
|
expect(template.kind).toBe(9010)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['h', 'sample-group-id'],
|
||||||
|
['e', 'event-id-1'],
|
||||||
|
['a', '1:pubkey:d-identifier'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NIP-29 join and leave requests', () => {
|
||||||
|
test('generateGroupJoinRequestEventTemplate includes optional code tag', () => {
|
||||||
|
const template = generateGroupJoinRequestEventTemplate('sample-group-id', 'invite-code', 'want in')
|
||||||
|
expect(template.kind).toBe(9021)
|
||||||
|
expect(template.tags).toEqual([
|
||||||
|
['h', 'sample-group-id'],
|
||||||
|
['code', 'invite-code'],
|
||||||
|
])
|
||||||
|
expect(template.content).toBe('want in')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateGroupLeaveRequestEventTemplate', () => {
|
||||||
|
const template = generateGroupLeaveRequestEventTemplate('sample-group-id', 'leaving')
|
||||||
|
expect(template.kind).toBe(9022)
|
||||||
|
expect(template.tags).toEqual([['h', 'sample-group-id']])
|
||||||
|
expect(template.content).toBe('leaving')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NIP-29 admins and members events', () => {
|
||||||
|
const admins: GroupAdmin[] = [
|
||||||
|
{ pubkey: 'admin-pubkey', label: 'boss', permissions: [GroupAdminPermission.PutUser] },
|
||||||
|
{ pubkey: 'admin-pubkey-2', label: '', permissions: [GroupAdminPermission.EditMetadata] },
|
||||||
|
]
|
||||||
|
const members: GroupMember[] = [{ pubkey: 'member-pubkey', label: 'pizza lover' }]
|
||||||
|
|
||||||
|
test('generateGroupAdminsEventTemplate and parse round-trip', () => {
|
||||||
|
const template = generateGroupAdminsEventTemplate(group, admins)
|
||||||
|
const event = makeEvent(template)
|
||||||
|
expect(validateGroupAdminsEvent(event)).toBe(true)
|
||||||
|
expect(parseGroupAdminsEvent(event)).toEqual(admins)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateGroupMembersEventTemplate and parse round-trip', () => {
|
||||||
|
const template = generateGroupMembersEventTemplate(group, members)
|
||||||
|
const event = makeEvent(template)
|
||||||
|
expect(validateGroupMembersEvent(event)).toBe(true)
|
||||||
|
expect(parseGroupMembersEvent(event)).toEqual(members)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -24,9 +24,16 @@ export type GroupMetadata = {
|
|||||||
pubkey: string
|
pubkey: string
|
||||||
name?: string
|
name?: string
|
||||||
picture?: string
|
picture?: string
|
||||||
|
banner?: string
|
||||||
about?: string
|
about?: string
|
||||||
isPublic?: boolean
|
isPrivate?: boolean
|
||||||
isOpen?: boolean
|
isRestricted?: boolean
|
||||||
|
isHidden?: boolean
|
||||||
|
isClosed?: boolean
|
||||||
|
hasLiveKit?: boolean
|
||||||
|
supportedKinds?: string[]
|
||||||
|
parent?: string
|
||||||
|
children?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,6 +80,30 @@ export enum GroupAdminPermission {
|
|||||||
CreateGroup = 'create-group',
|
CreateGroup = 'create-group',
|
||||||
DeleteGroup = 'delete-group',
|
DeleteGroup = 'delete-group',
|
||||||
CreateInvite = 'create-invite',
|
CreateInvite = 'create-invite',
|
||||||
|
UpdatePinList = 'update-pin-list',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroupMetadataTags(metadata: GroupMetadata): string[][] {
|
||||||
|
const tags: string[][] = []
|
||||||
|
metadata.name && tags.push(['name', metadata.name])
|
||||||
|
metadata.picture && tags.push(['picture', metadata.picture])
|
||||||
|
metadata.banner && tags.push(['banner', metadata.banner])
|
||||||
|
metadata.about && tags.push(['about', metadata.about])
|
||||||
|
metadata.isPrivate && tags.push(['private'])
|
||||||
|
metadata.isRestricted && tags.push(['restricted'])
|
||||||
|
metadata.isHidden && tags.push(['hidden'])
|
||||||
|
metadata.isClosed && tags.push(['closed'])
|
||||||
|
metadata.hasLiveKit && tags.push(['livekit'])
|
||||||
|
metadata.supportedKinds &&
|
||||||
|
metadata.supportedKinds.length > 0 &&
|
||||||
|
tags.push(['supported_kinds', ...metadata.supportedKinds])
|
||||||
|
metadata.parent && tags.push(['parent', metadata.parent])
|
||||||
|
metadata.children &&
|
||||||
|
metadata.children.forEach(child => {
|
||||||
|
tags.push(['child', child])
|
||||||
|
})
|
||||||
|
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,18 +113,11 @@ export enum GroupAdminPermission {
|
|||||||
* @returns An event template with the generated group metadata that can be signed later.
|
* @returns An event template with the generated group metadata that can be signed later.
|
||||||
*/
|
*/
|
||||||
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
|
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
|
||||||
const tags: string[][] = [['d', group.metadata.id]]
|
|
||||||
group.metadata.name && tags.push(['name', group.metadata.name])
|
|
||||||
group.metadata.picture && tags.push(['picture', group.metadata.picture])
|
|
||||||
group.metadata.about && tags.push(['about', group.metadata.about])
|
|
||||||
group.metadata.isPublic && tags.push(['public'])
|
|
||||||
group.metadata.isOpen && tags.push(['open'])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: '',
|
content: '',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
kind: 39000,
|
kind: 39000,
|
||||||
tags,
|
tags: [['d', group.metadata.id], ...buildGroupMetadataTags(group.metadata)],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,18 +319,39 @@ export function parseGroupMetadataEvent(event: Event): GroupMetadata {
|
|||||||
case 'picture':
|
case 'picture':
|
||||||
metadata.picture = value
|
metadata.picture = value
|
||||||
break
|
break
|
||||||
|
case 'banner':
|
||||||
|
metadata.banner = value
|
||||||
|
break
|
||||||
case 'about':
|
case 'about':
|
||||||
metadata.about = value
|
metadata.about = value
|
||||||
break
|
break
|
||||||
case 'public':
|
case 'private':
|
||||||
metadata.isPublic = true
|
metadata.isPrivate = true
|
||||||
break
|
break
|
||||||
case 'open':
|
case 'restricted':
|
||||||
metadata.isOpen = true
|
metadata.isRestricted = true
|
||||||
|
break
|
||||||
|
case 'hidden':
|
||||||
|
metadata.isHidden = true
|
||||||
|
break
|
||||||
|
case 'closed':
|
||||||
|
metadata.isClosed = true
|
||||||
|
break
|
||||||
|
case 'livekit':
|
||||||
|
metadata.hasLiveKit = true
|
||||||
|
break
|
||||||
|
case 'parent':
|
||||||
|
metadata.parent = value
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supportedKinds = event.tags.filter(([tag]) => tag === 'supported_kinds').flatMap(([, ...values]) => values)
|
||||||
|
if (supportedKinds.length > 0) metadata.supportedKinds = supportedKinds
|
||||||
|
|
||||||
|
const children = event.tags.filter(([tag]) => tag === 'child').map(([, value]) => value)
|
||||||
|
if (children.length > 0) metadata.children = children
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +466,131 @@ export async function fetchGroupMembersEvent({
|
|||||||
return groupMembersEvent
|
return groupMembersEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group roles event from the specified pool.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool object.
|
||||||
|
* @param {GroupReference} options.groupReference - The group reference object.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||||
|
* @returns {Promise<Event>} The group roles event that can be parsed later to get the group roles object.
|
||||||
|
* @throws {Error} If the group roles event is not found.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupRolesEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupRolesEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39003],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupRolesEvent) throw new Error(`roles for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupRolesEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group livekit participants event from the specified pool.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool object.
|
||||||
|
* @param {GroupReference} options.groupReference - The group reference object.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||||
|
* @returns {Promise<Event>} The group livekit participants event that can be parsed later to get the participants.
|
||||||
|
* @throws {Error} If the group livekit participants event is not found.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupLivekitParticipantsEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupLivekitParticipantsEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39004],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupLivekitParticipantsEvent)
|
||||||
|
throw new Error(`livekit participants for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupLivekitParticipantsEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group pinned events event from the specified pool.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool object.
|
||||||
|
* @param {GroupReference} options.groupReference - The group reference object.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||||
|
* @returns {Promise<Event>} The group pinned events event that can be parsed later to get the pinned events.
|
||||||
|
* @throws {Error} If the group pinned events event is not found.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupPinnedEventsEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupPinnedEventsEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39005],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupPinnedEventsEvent)
|
||||||
|
throw new Error(`pinned events for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupPinnedEventsEvent
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a group members event and returns an array of GroupMember objects.
|
* Parses a group members event and returns an array of GroupMember objects.
|
||||||
* @param event - The event to parse.
|
* @param event - The event to parse.
|
||||||
@@ -444,6 +614,406 @@ export function parseGroupMembersEvent(event: Event): GroupMember[] {
|
|||||||
return members
|
return members
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a NIP29 group role.
|
||||||
|
*/
|
||||||
|
export type GroupRole = {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template for the roles supported by a group.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @param roles - An array of group roles.
|
||||||
|
* @returns The generated event template with the group roles that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupRolesEventTemplate(group: Group, roles: GroupRole[]): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
for (const role of roles) {
|
||||||
|
const tag = ['role', role.name]
|
||||||
|
role.description && tag.push(role.description)
|
||||||
|
tags.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39003,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group roles event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is a valid group roles event, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateGroupRolesEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39003) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group roles event and returns an array of GroupRole objects.
|
||||||
|
*
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns An array of GroupRole objects.
|
||||||
|
* @throws Throws an error if the group roles event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupRolesEvent(event: Event): GroupRole[] {
|
||||||
|
if (!validateGroupRolesEvent(event)) throw new Error('invalid group roles event')
|
||||||
|
|
||||||
|
const roles: GroupRole[] = []
|
||||||
|
|
||||||
|
for (const [tag, name, description] of event.tags) {
|
||||||
|
if (tag !== 'role') continue
|
||||||
|
|
||||||
|
roles.push({ name, description })
|
||||||
|
}
|
||||||
|
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template for the livekit participants of a group.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @param participants - An array of pubkeys currently live in the group's AV rooms.
|
||||||
|
* @returns The generated event template with the livekit participants that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupLivekitParticipantsEventTemplate(group: Group, participants: string[]): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
participants.forEach(pubkey => {
|
||||||
|
tags.push(['participant', pubkey])
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39004,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group livekit participants event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is a valid group livekit participants event, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateGroupLivekitParticipantsEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39004) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group livekit participants event and returns an array of participant pubkeys.
|
||||||
|
*
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns An array of participant pubkeys.
|
||||||
|
* @throws Throws an error if the group livekit participants event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupLivekitParticipantsEvent(event: Event): string[] {
|
||||||
|
if (!validateGroupLivekitParticipantsEvent(event)) throw new Error('invalid group livekit participants event')
|
||||||
|
|
||||||
|
return event.tags.filter(([tag]) => tag === 'participant').map(([, pubkey]) => pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a reference to a pinned event in a NIP29 group.
|
||||||
|
*/
|
||||||
|
export type GroupPinnedEvent = {
|
||||||
|
type: 'e' | 'a'
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template for the events pinned in a group.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @param pinnedEvents - An array of references to pinned events, in display order.
|
||||||
|
* @returns The generated event template with the pinned events that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupPinnedEventsEventTemplate(group: Group, pinnedEvents: GroupPinnedEvent[]): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
pinnedEvents.forEach(pinnedEvent => {
|
||||||
|
tags.push([pinnedEvent.type, pinnedEvent.value])
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39005,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group pinned events event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is a valid group pinned events event, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateGroupPinnedEventsEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39005) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group pinned events event and returns an array of GroupPinnedEvent objects.
|
||||||
|
*
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns An array of GroupPinnedEvent objects.
|
||||||
|
* @throws Throws an error if the group pinned events event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupPinnedEventsEvent(event: Event): GroupPinnedEvent[] {
|
||||||
|
if (!validateGroupPinnedEventsEvent(event)) throw new Error('invalid group pinned events event')
|
||||||
|
|
||||||
|
const pinnedEvents: GroupPinnedEvent[] = []
|
||||||
|
|
||||||
|
for (const [tag, value] of event.tags) {
|
||||||
|
if (tag !== 'e' && tag !== 'a') continue
|
||||||
|
|
||||||
|
pinnedEvents.push({ type: tag, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return pinnedEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a group moderation event template. These events require the `h` tag and
|
||||||
|
* may optionally carry timeline references in `previous` tags.
|
||||||
|
*/
|
||||||
|
function generateGroupModerationEventTemplate(
|
||||||
|
kind: number,
|
||||||
|
groupId: string,
|
||||||
|
content: string,
|
||||||
|
tags: string[][],
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
const allTags: string[][] = [['h', groupId], ...tags]
|
||||||
|
previous && previous.length > 0 && allTags.push(['previous', ...previous])
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind,
|
||||||
|
tags: allTags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a `put-user` (kind:9000) moderation event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group.
|
||||||
|
* @param pubkey - The pubkey of the user to add or update.
|
||||||
|
* @param roles - Optional roles to assign to the user.
|
||||||
|
* @param reason - Optional reason for the action.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generatePutUserEventTemplate(
|
||||||
|
groupId: string,
|
||||||
|
pubkey: string,
|
||||||
|
roles?: string[],
|
||||||
|
reason?: string,
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
return generateGroupModerationEventTemplate(9000, groupId, reason || '', [['p', pubkey, ...(roles || [])]], previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a `remove-user` (kind:9001) moderation event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group.
|
||||||
|
* @param pubkey - The pubkey of the user to remove.
|
||||||
|
* @param reason - Optional reason for the action.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateRemoveUserEventTemplate(
|
||||||
|
groupId: string,
|
||||||
|
pubkey: string,
|
||||||
|
reason?: string,
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
return generateGroupModerationEventTemplate(9001, groupId, reason || '', [['p', pubkey]], previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an `edit-metadata` (kind:9002) moderation event template carrying
|
||||||
|
* all the metadata fields of the group.
|
||||||
|
*
|
||||||
|
* @param group - The group object with the updated metadata.
|
||||||
|
* @param reason - Optional reason for the action.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateEditGroupMetadataEventTemplate(
|
||||||
|
group: Group,
|
||||||
|
reason?: string,
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
return generateGroupModerationEventTemplate(
|
||||||
|
9002,
|
||||||
|
group.metadata.id,
|
||||||
|
reason || '',
|
||||||
|
buildGroupMetadataTags(group.metadata),
|
||||||
|
previous,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a `delete-event` (kind:9005) moderation event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group.
|
||||||
|
* @param eventId - The id of the event to delete.
|
||||||
|
* @param reason - Optional reason for the action.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateDeleteEventEventTemplate(
|
||||||
|
groupId: string,
|
||||||
|
eventId: string,
|
||||||
|
reason?: string,
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
return generateGroupModerationEventTemplate(9005, groupId, reason || '', [['e', eventId]], previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a `create-group` (kind:9007) moderation event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group to create.
|
||||||
|
* @param reason - Optional reason for the action.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateCreateGroupEventTemplate(groupId: string, reason?: string, previous?: string[]): EventTemplate {
|
||||||
|
return generateGroupModerationEventTemplate(9007, groupId, reason || '', [], previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a `delete-group` (kind:9008) moderation event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group to delete.
|
||||||
|
* @param reason - Optional reason for the action.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateDeleteGroupEventTemplate(groupId: string, reason?: string, previous?: string[]): EventTemplate {
|
||||||
|
return generateGroupModerationEventTemplate(9008, groupId, reason || '', [], previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a `create-invite` (kind:9009) moderation event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group.
|
||||||
|
* @param code - An arbitrary invite code.
|
||||||
|
* @param reason - Optional reason for the action.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateCreateInviteEventTemplate(
|
||||||
|
groupId: string,
|
||||||
|
code: string,
|
||||||
|
reason?: string,
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
return generateGroupModerationEventTemplate(9009, groupId, reason || '', [['code', code]], previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an `update-pin-list` (kind:9010) moderation event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group.
|
||||||
|
* @param pinnedEvents - The full ordered list of pinned events.
|
||||||
|
* @param reason - Optional reason for the action.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateUpdatePinListEventTemplate(
|
||||||
|
groupId: string,
|
||||||
|
pinnedEvents: GroupPinnedEvent[],
|
||||||
|
reason?: string,
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
const tags = pinnedEvents.map(pinnedEvent => [pinnedEvent.type, pinnedEvent.value])
|
||||||
|
return generateGroupModerationEventTemplate(9010, groupId, reason || '', tags, previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a group join request (kind:9021) event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group.
|
||||||
|
* @param inviteCode - Optional invite code to be preauthorized by the relay.
|
||||||
|
* @param reason - Optional reason for the request.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupJoinRequestEventTemplate(
|
||||||
|
groupId: string,
|
||||||
|
inviteCode?: string,
|
||||||
|
reason?: string,
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
const tags: string[][] = [['h', groupId]]
|
||||||
|
inviteCode && tags.push(['code', inviteCode])
|
||||||
|
previous && previous.length > 0 && tags.push(['previous', ...previous])
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: reason || '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 9021,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a group leave request (kind:9022) event template.
|
||||||
|
*
|
||||||
|
* @param groupId - The id of the group.
|
||||||
|
* @param reason - Optional reason for the request.
|
||||||
|
* @param previous - Optional timeline references.
|
||||||
|
* @returns The generated event template that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupLeaveRequestEventTemplate(
|
||||||
|
groupId: string,
|
||||||
|
reason?: string,
|
||||||
|
previous?: string[],
|
||||||
|
): EventTemplate {
|
||||||
|
const tags: string[][] = [['h', groupId]]
|
||||||
|
previous && previous.length > 0 && tags.push(['previous', ...previous])
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: reason || '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 9022,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
|
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
|
||||||
* If the normalized relay URL is not provided, it will be obtained using the group reference.
|
* If the normalized relay URL is not provided, it will be obtained using the group reference.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "2.24.1",
|
"version": "2.24.2",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user