diff --git a/index.ts b/index.ts index ea77374..485887c 100644 --- a/index.ts +++ b/index.ts @@ -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' diff --git a/jsr.json b/jsr.json index 2356aad..384d803 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@nostr/tools", - "version": "2.23.3", + "version": "2.23.4", "exports": { ".": "./index.ts", "./core": "./core.ts", diff --git a/nip22.test.ts b/nip22.test.ts new file mode 100644 index 0000000..7884349 --- /dev/null +++ b/nip22.test.ts @@ -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: [], + }) + }) +}) diff --git a/nip22.ts b/nip22.ts new file mode 100644 index 0000000..edcf011 --- /dev/null +++ b/nip22.ts @@ -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): { + /** + * 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 = { + 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 +} diff --git a/package.json b/package.json index ce76104..f41eca3 100644 --- a/package.json +++ b/package.json @@ -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",