nip22: like nip10, but for kind:1111 and adjacent kinds.

This commit is contained in:
fiatjaf
2026-05-18 20:52:05 -03:00
parent 3cd26d7d76
commit a9e89e16e3
5 changed files with 322 additions and 3 deletions
+1
View File
@@ -13,6 +13,7 @@ export * as nip17 from './nip17.ts'
export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts'
export * as nip22 from './nip22.ts'
export * as nip25 from './nip25.ts'
export * as nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nostr/tools",
"version": "2.23.3",
"version": "2.23.4",
"exports": {
".": "./index.ts",
"./core": "./core.ts",
+121
View File
@@ -0,0 +1,121 @@
import { describe, test, expect } from 'bun:test'
import { parse } from './nip22.ts'
describe('parse NIP22 comment references', () => {
test('top-level comment on addressable event', () => {
expect(
parse({
tags: [
['A', '30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7', 'wss://example.relay'],
['K', '30023'],
['P', '3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289', 'wss://example.relay'],
['a', '30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7', 'wss://example.relay'],
['e', '5b4fc7fed15672fefe65d2426f67197b71ccc82aa0cc8a9e94f683eb78e07651', 'wss://example.relay', '3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289'],
['k', '30023'],
['p', '3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289', 'wss://example.relay'],
],
}),
).toEqual({
root: {
kind: 30023,
pubkey: '3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289',
identifier: 'f9347ca7',
relays: ['wss://example.relay'],
},
rootKind: 30023,
reply: {
kind: 30023,
pubkey: '3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289',
identifier: 'f9347ca7',
relays: ['wss://example.relay'],
},
replyKind: 30023,
mentions: [],
quotes: [],
profiles: [
{
pubkey: '3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289',
relays: ['wss://example.relay'],
},
{
pubkey: '3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289',
relays: ['wss://example.relay'],
},
],
})
})
test('reply to comment with quote', () => {
expect(
parse({
tags: [
['E', '768ac8720cdeb59227cf95e98b66560ef03d8bc9a90d721779e76e68fb42f5e6', 'wss://example.relay', 'fd913cd6fa9edb8405750cd02a8bbe16e158b8676c0e69fdc27436cc4a54cc9a'],
['K', '1063'],
['P', 'fd913cd6fa9edb8405750cd02a8bbe16e158b8676c0e69fdc27436cc4a54cc9a'],
['e', '5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36', 'wss://example.relay', '93ef2ebaaf9554661f33e79949007900bbc535d239a4c801c33a4d67d3e7f546'],
['k', '1111'],
['p', '93ef2ebaaf9554661f33e79949007900bbc535d239a4c801c33a4d67d3e7f546', 'wss://parent.relay'],
['q', '680f14330f3f8b24d9b86137ad9a6ef0c7a955c31cbea02e9f53a25a1fce9d6f', 'wss://quote.relay', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
],
}),
).toEqual({
root: {
id: '768ac8720cdeb59227cf95e98b66560ef03d8bc9a90d721779e76e68fb42f5e6',
relays: ['wss://example.relay'],
author: 'fd913cd6fa9edb8405750cd02a8bbe16e158b8676c0e69fdc27436cc4a54cc9a',
},
rootKind: 1063,
reply: {
id: '5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36',
relays: ['wss://example.relay', 'wss://parent.relay'],
author: '93ef2ebaaf9554661f33e79949007900bbc535d239a4c801c33a4d67d3e7f546',
},
replyKind: 1111,
mentions: [],
quotes: [
{
id: '680f14330f3f8b24d9b86137ad9a6ef0c7a955c31cbea02e9f53a25a1fce9d6f',
relays: ['wss://quote.relay'],
author: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
],
profiles: [
{
pubkey: 'fd913cd6fa9edb8405750cd02a8bbe16e158b8676c0e69fdc27436cc4a54cc9a',
relays: ['wss://example.relay'],
},
{
pubkey: '93ef2ebaaf9554661f33e79949007900bbc535d239a4c801c33a4d67d3e7f546',
relays: ['wss://example.relay', 'wss://parent.relay'],
},
],
})
})
test('comment on external identifier', () => {
expect(
parse({
tags: [
['I', 'https://abc.com/articles/1'],
['K', 'web'],
['i', 'https://abc.com/articles/1'],
['k', 'web'],
],
}),
).toEqual({
root: {
value: 'https://abc.com/articles/1',
hint: undefined,
},
rootKind: 'web',
reply: {
value: 'https://abc.com/articles/1',
hint: undefined,
},
replyKind: 'web',
mentions: [],
quotes: [],
profiles: [],
})
})
})
+191
View File
@@ -0,0 +1,191 @@
import type { Event } from './core.ts'
import type { AddressPointer, EventPointer, ProfilePointer } from './nip19.ts'
export type ExternalPointer = {
value: string
hint?: string
}
function parseKind(kind: string | undefined): number | string | undefined {
if (!kind) return undefined
return /^\d+$/.test(kind) ? parseInt(kind, 10) : kind
}
function parseAddressPointer(value: string, relayUrl?: string): AddressPointer | undefined {
const idx = value.indexOf(':')
const idx2 = value.indexOf(':', idx + 1)
if (idx === -1 || idx2 === -1) return undefined
const kind = parseInt(value.slice(0, idx), 10)
if (Number.isNaN(kind)) return undefined
return {
kind,
pubkey: value.slice(idx + 1, idx2),
identifier: value.slice(idx2 + 1),
relays: relayUrl ? [relayUrl] : [],
}
}
function parsePointer(tag: string[]): EventPointer | AddressPointer | ExternalPointer | undefined {
switch (tag[0]) {
case 'E':
case 'e':
if (!tag[1]) return undefined
return {
id: tag[1],
relays: tag[2] ? [tag[2]] : [],
author: tag[3],
}
case 'A':
case 'a':
if (!tag[1]) return undefined
return parseAddressPointer(tag[1], tag[2])
case 'I':
case 'i':
if (!tag[1]) return undefined
return {
value: tag[1],
hint: tag[2],
}
}
}
function parseQuote(tag: string[]): EventPointer | AddressPointer | ExternalPointer | undefined {
if (!tag[1]) return undefined
if (tag[1].includes(':')) {
return parseAddressPointer(tag[1], tag[2])
}
return {
id: tag[1],
relays: tag[2] ? [tag[2]] : [],
author: tag[3],
}
}
function choosePointer(
candidates: Array<{ tagName: string; pointer: EventPointer | AddressPointer | ExternalPointer }>,
): EventPointer | AddressPointer | ExternalPointer | undefined {
return (
candidates.findLast(candidate => candidate.tagName === 'A' || candidate.tagName === 'a')?.pointer ||
candidates.findLast(candidate => candidate.tagName === 'I' || candidate.tagName === 'i')?.pointer ||
candidates.findLast(candidate => candidate.tagName === 'E' || candidate.tagName === 'e')?.pointer
)
}
function inheritRelayHints(
pointer: EventPointer | AddressPointer | ExternalPointer | undefined,
profiles: ProfilePointer[],
) {
if (!pointer || !('id' in pointer) || !pointer.author) return
const author = profiles.find(profile => profile.pubkey === pointer.author)
if (!author || !author.relays) return
if (!pointer.relays) {
pointer.relays = []
}
author.relays.forEach(url => {
if (pointer.relays!.indexOf(url) === -1) pointer.relays!.push(url)
})
author.relays = pointer.relays
}
export function parse(event: Pick<Event, 'tags'>): {
/**
* Pointer to root scope.
*/
root: EventPointer | AddressPointer | ExternalPointer | undefined
/**
* Kind of root scope from `K` tag.
*/
rootKind: number | string | undefined
/**
* Pointer to parent item being replied to.
*/
reply: EventPointer | AddressPointer | ExternalPointer | undefined
/**
* Kind of parent item from `k` tag.
*/
replyKind: number | string | undefined
/**
* Reserved for extra referenced items.
*/
mentions: (EventPointer | AddressPointer | ExternalPointer)[]
/**
* Pointers directly quoted with `q` tags.
*/
quotes: (EventPointer | AddressPointer | ExternalPointer)[]
/**
* Root and parent authors.
*/
profiles: ProfilePointer[]
} {
const result: ReturnType<typeof parse> = {
root: undefined,
rootKind: undefined,
reply: undefined,
replyKind: undefined,
mentions: [],
quotes: [],
profiles: [],
}
const rootCandidates: Array<{ tagName: string; pointer: EventPointer | AddressPointer | ExternalPointer }> = []
const replyCandidates: Array<{ tagName: string; pointer: EventPointer | AddressPointer | ExternalPointer }> = []
for (const tag of event.tags) {
if ((tag[0] === 'E' || tag[0] === 'A' || tag[0] === 'I') && tag[1]) {
const pointer = parsePointer(tag)
if (pointer) rootCandidates.push({ tagName: tag[0], pointer })
continue
}
if ((tag[0] === 'e' || tag[0] === 'a' || tag[0] === 'i') && tag[1]) {
const pointer = parsePointer(tag)
if (pointer) replyCandidates.push({ tagName: tag[0], pointer })
continue
}
if (tag[0] === 'K') {
result.rootKind = parseKind(tag[1])
continue
}
if (tag[0] === 'k') {
result.replyKind = parseKind(tag[1])
continue
}
if (tag[0] === 'q') {
const pointer = parseQuote(tag)
if (pointer) result.quotes.push(pointer)
continue
}
if ((tag[0] === 'P' || tag[0] === 'p') && tag[1]) {
result.profiles.push({
pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [],
})
}
}
result.root = choosePointer(rootCandidates)
result.reply = choosePointer(replyCandidates)
inheritRelayHints(result.root, result.profiles)
inheritRelayHints(result.reply, result.profiles)
result.quotes.forEach(pointer => inheritRelayHints(pointer, result.profiles))
return result
}
+8 -2
View File
@@ -1,11 +1,11 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.23.3",
"version": "2.23.4",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
"url": "https://github.com/nbd-wtf/nostr-tools.git"
"url": "git+https://github.com/nbd-wtf/nostr-tools.git"
},
"files": [
"lib"
@@ -144,6 +144,12 @@
"require": "./lib/cjs/nip21.js",
"types": "./lib/types/nip21.d.ts"
},
"./nip22": {
"source": "./nip22.ts",
"import": "./lib/esm/nip22.js",
"require": "./lib/cjs/nip22.js",
"types": "./lib/types/nip22.d.ts"
},
"./nip25": {
"source": "./nip25.ts",
"import": "./lib/esm/nip25.js",