From 01880b6fb5a65c49cb144b7c0f170bf7f704deeb Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Thu, 20 Nov 2025 09:46:41 -0300 Subject: [PATCH] nip27: parse emoji shortcodes and hashtags too. --- .eslintrc.json | 3 +- jsr.json | 2 +- nip27.test.ts | 32 ++++++++++++++ nip27.ts | 115 +++++++++++++++++++++++++++++++++++++------------ package.json | 2 +- 5 files changed, 123 insertions(+), 31 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 34616bc..9837f30 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -138,6 +138,7 @@ "valid-typeof": 2, "wrap-iife": [2, "any"], "yield-star-spacing": [2, "both"], - "yoda": [0] + "yoda": [0], + "no-labels": [0] } } diff --git a/jsr.json b/jsr.json index a0116fc..ab017f9 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@nostr/tools", - "version": "2.17.4", + "version": "2.18.0", "exports": { ".": "./index.ts", "./core": "./core.ts", diff --git a/nip27.test.ts b/nip27.test.ts index 141a8ad..0555d77 100644 --- a/nip27.test.ts +++ b/nip27.test.ts @@ -1,5 +1,6 @@ import { test, expect } from 'bun:test' import { parse } from './nip27.ts' +import { NostrEvent } from './core.ts' test('first: parse simple content with 1 url and 1 nostr uri', () => { const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg` @@ -75,3 +76,34 @@ test('third: parse complex content with 4 nostr uris and 3 urls', () => { { type: 'url', url: 'https://example.com/docs' }, ]) }) + +test('parse content with hashtags and emoji shortcodes', () => { + const event: NostrEvent = { + kind: 1, + tags: [ + ['emoji', 'star', 'https://example.com/star.png'], + ['emoji', 'alpaca', 'https://example.com/alpaca.png'], + ], + content: + 'hey nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out :alpaca::alpaca: #alpaca at wss://alpaca.com! :star:', + created_at: 1234567890, + pubkey: 'dummy', + id: 'dummy', + sig: 'dummy', + } + const blocks = Array.from(parse(event)) + + expect(blocks).toEqual([ + { type: 'text', text: 'hey ' }, + { type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } }, + { type: 'text', text: ' check out ' }, + { type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png' }, + { type: 'emoji', shortcode: 'alpaca', url: 'https://example.com/alpaca.png' }, + { type: 'text', text: ' ' }, + { type: 'hashtag', value: 'alpaca' }, + { type: 'text', text: ' at ' }, + { type: 'relay', url: 'wss://alpaca.com/' }, + { type: 'text', text: '! ' }, + { type: 'emoji', shortcode: 'star', url: 'https://example.com/star.png' }, + ]) +}) diff --git a/nip27.ts b/nip27.ts index ffcb0a4..823e308 100644 --- a/nip27.ts +++ b/nip27.ts @@ -1,3 +1,4 @@ +import { NostrEvent } from './core.ts' import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts' export type Block = @@ -29,27 +30,67 @@ export type Block = type: 'audio' url: string } + | { + type: 'emoji' + shortcode: string + url: string + } + | { + type: 'hashtag' + value: string + } const noCharacter = /\W/m const noURLCharacter = /\W |\W$|$|,| /m +const MAX_HASHTAG_LENGTH = 42 + +export function* parse(content: string | NostrEvent): Iterable { + let emojis: { type: 'emoji'; shortcode: string; url: string }[] = [] + if (typeof content !== 'string') { + for (let i = 0; i < content.tags.length; i++) { + const tag = content.tags[i] + if (tag[0] === 'emoji' && tag.length >= 3) { + emojis.push({ type: 'emoji', shortcode: tag[1], url: tag[2] }) + } + } + content = content.content + } -export function* parse(content: string): Iterable { const max = content.length let prevIndex = 0 let index = 0 - while (index < max) { - let u = content.indexOf(':', index) - if (u === -1) { + mainloop: while (index < max) { + const u = content.indexOf(':', index) + const h = content.indexOf('#', index) + if (u === -1 && h === -1) { // reached end - break + break mainloop } - if (content.substring(u - 5, u) === 'nostr') { - const m = content.substring(u + 60).match(noCharacter) + if (u === -1 || (h >= 0 && h < u)) { + // parse hashtag + if (h === 0 || content[h - 1] === ' ') { + const m = content.slice(h + 1, h + MAX_HASHTAG_LENGTH).match(noCharacter) + const end = m ? h + 1 + m.index! : max + yield { type: 'text', text: content.slice(prevIndex, h) } + yield { type: 'hashtag', value: content.slice(h + 1, end) } + index = end + prevIndex = index + continue mainloop + } + + // ignore this, it is nothing + index = h + 1 + continue mainloop + } + + // otherwise parse things that have an ":" + if (content.slice(u - 5, u) === 'nostr') { + const m = content.slice(u + 60).match(noCharacter) const end = m ? u + 60 + m.index! : max try { let pointer: ProfilePointer | AddressPointer | EventPointer - let { data, type } = decode(content.substring(u + 1, end)) + let { data, type } = decode(content.slice(u + 1, end)) switch (type) { case 'npub': @@ -65,89 +106,107 @@ export function* parse(content: string): Iterable { } if (prevIndex !== u - 5) { - yield { type: 'text', text: content.substring(prevIndex, u - 5) } + yield { type: 'text', text: content.slice(prevIndex, u - 5) } } yield { type: 'reference', pointer } index = end prevIndex = index - continue + continue mainloop } catch (_err) { // ignore this, not a valid nostr uri index = u + 1 - continue + continue mainloop } - } else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') { - const m = content.substring(u + 4).match(noURLCharacter) + } else if (content.slice(u - 5, u) === 'https' || content.slice(u - 4, u) === 'http') { + const m = content.slice(u + 4).match(noURLCharacter) const end = m ? u + 4 + m.index! : max const prefixLen = content[u - 1] === 's' ? 5 : 4 try { - let url = new URL(content.substring(u - prefixLen, end)) + let url = new URL(content.slice(u - prefixLen, end)) if (url.hostname.indexOf('.') === -1) { throw new Error('invalid url') } if (prevIndex !== u - prefixLen) { - yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) } + yield { type: 'text', text: content.slice(prevIndex, u - prefixLen) } } if (/\.(png|jpe?g|gif|webp)$/i.test(url.pathname)) { yield { type: 'image', url: url.toString() } index = end prevIndex = index - continue + continue mainloop } if (/\.(mp4|avi|webm|mkv)$/i.test(url.pathname)) { yield { type: 'video', url: url.toString() } index = end prevIndex = index - continue + continue mainloop } if (/\.(mp3|aac|ogg|opus)$/i.test(url.pathname)) { yield { type: 'audio', url: url.toString() } index = end prevIndex = index - continue + continue mainloop } yield { type: 'url', url: url.toString() } index = end prevIndex = index - continue + continue mainloop } catch (_err) { // ignore this, not a valid url index = end + 1 - continue + continue mainloop } - } else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') { - const m = content.substring(u + 4).match(noURLCharacter) + } else if (content.slice(u - 3, u) === 'wss' || content.slice(u - 2, u) === 'ws') { + const m = content.slice(u + 4).match(noURLCharacter) const end = m ? u + 4 + m.index! : max const prefixLen = content[u - 1] === 's' ? 3 : 2 try { - let url = new URL(content.substring(u - prefixLen, end)) + let url = new URL(content.slice(u - prefixLen, end)) if (url.hostname.indexOf('.') === -1) { throw new Error('invalid ws url') } if (prevIndex !== u - prefixLen) { - yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) } + yield { type: 'text', text: content.slice(prevIndex, u - prefixLen) } } yield { type: 'relay', url: url.toString() } index = end prevIndex = index - continue + continue mainloop } catch (_err) { // ignore this, not a valid url index = end + 1 - continue + continue mainloop } } else { + // try to parse an emoji shortcode + for (let e = 0; e < emojis.length; e++) { + const emoji = emojis[e] + if ( + content[u + emoji.shortcode.length + 1] === ':' && + content.slice(u + 1, u + emoji.shortcode.length + 1) === emoji.shortcode + ) { + // found an emoji + if (prevIndex !== u) { + yield { type: 'text', text: content.slice(prevIndex, u) } + } + yield emoji + index = u + emoji.shortcode.length + 2 + prevIndex = index + continue mainloop + } + } + // ignore this, it is nothing index = u + 1 - continue + continue mainloop } } if (prevIndex !== max) { - yield { type: 'text', text: content.substring(prevIndex) } + yield { type: 'text', text: content.slice(prevIndex) } } } diff --git a/package.json b/package.json index 9213c48..ecd5f52 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "nostr-tools", - "version": "2.17.4", + "version": "2.18.0", "description": "Tools for making a Nostr client.", "repository": { "type": "git",