From eda4382524f24c0b2f548b9ef294ad387b0022fb Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Thu, 6 Apr 2023 19:19:09 +0900 Subject: [PATCH] refactor: change parser to parse raw text content --- .../textNote/TextNoteContentDisplay.tsx | 22 ++- src/core/parseTextNote.test.ts | 172 ++++++++---------- src/core/parseTextNote.ts | 120 ++++++------ 3 files changed, 153 insertions(+), 161 deletions(-) diff --git a/src/components/textNote/TextNoteContentDisplay.tsx b/src/components/textNote/TextNoteContentDisplay.tsx index 4094f42..b6a1396 100644 --- a/src/components/textNote/TextNoteContentDisplay.tsx +++ b/src/components/textNote/TextNoteContentDisplay.tsx @@ -1,5 +1,5 @@ import { For } from 'solid-js'; -import parseTextNote, { type ParsedTextNoteNode } from '@/core/parseTextNote'; +import parseTextNote, { resolveTagReference, type ParsedTextNoteNode } from '@/core/parseTextNote'; import type { Event as NostrEvent } from 'nostr-tools'; import PlainTextDisplay from '@/components/textNote/PlainTextDisplay'; import MentionedUserDisplay from '@/components/textNote/MentionedUserDisplay'; @@ -21,19 +21,23 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { const { config } = useConfig(); const event = () => eventWrapper(props.event); return ( - + {(item: ParsedTextNoteNode) => { if (item.type === 'PlainText') { return ; } - if (item.type === 'MentionedUser') { - return ; - } - if (item.type === 'MentionedEvent') { - if (props.embedding) { - return ; + if (item.type === 'TagReference') { + const resolved = resolveTagReference(item, props.event); + if (resolved == null) return null; + if (resolved.type === 'MentionedUser') { + return ; + } + if (resolved.type === 'MentionedEvent') { + if (props.embedding) { + return ; + } + return ; } - return ; } if (item.type === 'Bech32Entity') { if (item.data.type === 'note' && props.embedding) { diff --git a/src/core/parseTextNote.test.ts b/src/core/parseTextNote.test.ts index aad039c..65ed22b 100644 --- a/src/core/parseTextNote.test.ts +++ b/src/core/parseTextNote.test.ts @@ -1,7 +1,8 @@ import assert from 'assert'; import { describe, it } from 'vitest'; +import { type Event as NostrEvent } from 'nostr-tools'; -import parseTextNote, { type ParsedTextNoteNode } from './parseTextNote'; +import parseTextNote, { resolveTagReference, type ParsedTextNoteNode, TagReference } from './parseTextNote'; describe('parseTextNote', () => { /* @@ -21,15 +22,7 @@ describe('parseTextNote', () => { */ it('should parse text note with the url with hash', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: 'this is url\nhttps://github.com/syusui-s/rabbit/#readme #rabbit', - tags: [], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('this is url\nhttps://github.com/syusui-s/rabbit/#readme #rabbit'); const expected: ParsedTextNoteNode[] = [ { type: 'PlainText', content: 'this is url\n' }, @@ -42,15 +35,7 @@ describe('parseTextNote', () => { }); it('should parse text note with the url with hash and hashtag', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: 'this is url\nhttps://github.com/syusui-s/rabbit/#readme #rabbit', - tags: [], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('this is url\nhttps://github.com/syusui-s/rabbit/#readme #rabbit'); const expected: ParsedTextNoteNode[] = [ { type: 'PlainText', content: 'this is url\n' }, @@ -63,15 +48,7 @@ describe('parseTextNote', () => { }); it('should parse text note which includes punycode URL', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: 'This is Japanese domain: https://xn--p8j9a0d9c9a.xn--q9jyb4c/', - tags: [], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('This is Japanese domain: https://xn--p8j9a0d9c9a.xn--q9jyb4c/'); const expected: ParsedTextNoteNode[] = [ { type: 'PlainText', content: 'This is Japanese domain: ' }, @@ -82,16 +59,7 @@ describe('parseTextNote', () => { }); it('should parse text note which includes image URLs', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: - 'https://i.gyazo.com/8f177b9953fdb9513ad00d0743d9c608.png\nhttps://i.gyazo.com/346ad7260f6a999720c2d13317ff795f.jpg', - tags: [], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('https://i.gyazo.com/8f177b9953fdb9513ad00d0743d9c608.png\nhttps://i.gyazo.com/346ad7260f6a999720c2d13317ff795f.jpg'); const expected: ParsedTextNoteNode[] = [ { type: 'URL', content: 'https://i.gyazo.com/8f177b9953fdb9513ad00d0743d9c608.png' }, @@ -103,15 +71,7 @@ describe('parseTextNote', () => { }); it('should parse text note which includes URL with + symbol', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: 'this is my page\nhttps://example.com/abc+def?q=ghi+jkl#lmn+opq', - tags: [], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('this is my page\nhttps://example.com/abc+def?q=ghi+jkl#lmn+opq'); const expected: ParsedTextNoteNode[] = [ { type: 'PlainText', content: 'this is my page\n' }, @@ -123,15 +83,7 @@ describe('parseTextNote', () => { // it('should parse text note which includes URL with + symbol', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: 'I wrote this page\nhttps://example.com/test(test)?q=(q)#(h)', - tags: [], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('I wrote this page\nhttps://example.com/test(test)?q=(q)#(h)'); const expected: ParsedTextNoteNode[] = [ { type: 'PlainText', content: 'I wrote this page\n' }, @@ -145,15 +97,7 @@ describe('parseTextNote', () => { }); it('should parse text note which includes wss URL', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: 'this is my using relays: wss://relay.damus.io, wss://relay.snort.social', - tags: [], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('this is my using relays: wss://relay.damus.io, wss://relay.snort.social'); const expected: ParsedTextNoteNode[] = [ { type: 'PlainText', content: 'this is my using relays: ' }, @@ -166,50 +110,20 @@ describe('parseTextNote', () => { }); it('should parse text note with pubkey mentions', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: 'this is pubkey\n#[0] #[1]', - tags: [ - ['p', '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972'], - ['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'], - ], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('this is pubkey\n#[0] #[1]'); const expected: ParsedTextNoteNode[] = [ { type: 'PlainText', content: 'this is pubkey\n' }, - { - type: 'MentionedUser', - tagIndex: 0, - content: '#[0]', - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }, + { type: 'TagReference', tagIndex: 0, content: '#[0]'}, { type: 'PlainText', content: ' ' }, - { - type: 'MentionedUser', - tagIndex: 1, - content: '#[1]', - pubkey: '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc', - }, + { type: 'TagReference', tagIndex: 1, content: '#[1]'}, ]; assert.deepStrictEqual(parsed, expected); }); it('should parse text note which includes npub string', () => { - const parsed = parseTextNote({ - id: '', - sig: '', - kind: 1, - content: - 'this is pubkey\nnpub1srf6g8v2qpnecqg9l2kzehmkg0ym5f5rtnlsj6lhl8r6pmhger7q5mtt3q\nhello', - tags: [], - created_at: 1678377182, - pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', - }); + const parsed = parseTextNote('this is pubkey\nnpub1srf6g8v2qpnecqg9l2kzehmkg0ym5f5rtnlsj6lhl8r6pmhger7q5mtt3q\nhello'); const expected: ParsedTextNoteNode[] = [ { type: 'PlainText', content: 'this is pubkey\n' }, @@ -227,3 +141,63 @@ describe('parseTextNote', () => { assert.deepStrictEqual(parsed, expected); }); }); + +describe('resolveTagReference', () => { + it('should resolve a tag reference refers a user', () => { + const tagReference: TagReference = { + type: 'TagReference', + tagIndex: 1, + content: '#[1]', + }; + const dummyEvent: NostrEvent = { + id: '', + sig: '', + kind: 1, + content: '#[1]', + tags: [ + ['p', '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972'], + ['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'], + ], + created_at: 1678377182, + pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', + }; + const result = resolveTagReference(tagReference, dummyEvent); + const expected = { + type: 'MentionedUser', + tagIndex: 1, + content: '#[1]', + pubkey: '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc', + }; + + assert.deepStrictEqual(result, expected); + }); + + it('should resolve a tag reference refers an other text note', () => { + const tagReference: TagReference = { + type: 'TagReference', + tagIndex: 1, + content: '#[1]', + }; + const dummyEvent: NostrEvent = { + id: '', + sig: '', + kind: 1, + content: '', + tags: [ + ['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'], + ['e', 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f212', '', 'reply'], + ], + created_at: 1678377182, + pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', + }; + const result = resolveTagReference(tagReference, dummyEvent); + const expected = { + type: 'MentionedEvent', + tagIndex: 1, + marker: 'reply', + content: '#[1]', + eventId: 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f212', + }; + assert.deepStrictEqual(result, expected); + }); +}); diff --git a/src/core/parseTextNote.ts b/src/core/parseTextNote.ts index d44d98f..8bf8086 100644 --- a/src/core/parseTextNote.ts +++ b/src/core/parseTextNote.ts @@ -11,19 +11,10 @@ export type PlainText = { content: string; }; -export type MentionedEvent = { - type: 'MentionedEvent'; - content: string; +export type TagReference = { + type: 'TagReference'; tagIndex: number; - eventId: string; - marker: 'reply' | 'root' | 'mention' | undefined; -}; - -export type MentionedUser = { - type: 'MentionedUser'; content: string; - tagIndex: number; - pubkey: string; }; export type Bech32Entity = { @@ -46,16 +37,25 @@ export type UrlText = { content: string; }; -export type ParsedTextNoteNode = - | PlainText - | MentionedEvent - | MentionedUser - | Bech32Entity - | HashTag - | UrlText; +export type ParsedTextNoteNode = PlainText | TagReference | Bech32Entity | HashTag | UrlText; export type ParsedTextNote = ParsedTextNoteNode[]; +export type MentionedEvent = { + type: 'MentionedEvent'; + content: string; + tagIndex: number; + eventId: string; + marker: 'reply' | 'root' | 'mention' | undefined; +}; + +export type MentionedUser = { + type: 'MentionedUser'; + content: string; + tagIndex: number; + pubkey: string; +}; + const tagRefRegex = /(?:#\[(?\d+)\])/g; const hashTagRegex = /#(?[^[-^`:-@!-/{-~\d\s][^[-^`:-@!-/{-~\s]+)/g; // raw NIP-19 codes, NIP-21 links (NIP-27) @@ -64,19 +64,19 @@ const mentionRegex = /(?:nostr:)?(?(npub|note|nprofile|nevent)1[ac-hj-n const urlRegex = /(?(?:https?|wss?):\/\/[-a-zA-Z0-9.:]+(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g; -const parseTextNote = (event: NostrEvent): ParsedTextNote => { +const parseTextNote = (textNoteContent: string) => { const matches = [ - ...event.content.matchAll(tagRefRegex), - ...event.content.matchAll(hashTagRegex), - ...event.content.matchAll(mentionRegex), - ...event.content.matchAll(urlRegex), + ...textNoteContent.matchAll(tagRefRegex), + ...textNoteContent.matchAll(hashTagRegex), + ...textNoteContent.matchAll(mentionRegex), + ...textNoteContent.matchAll(urlRegex), ].sort((a, b) => (a.index as number) - (b.index as number)); let pos = 0; const result: ParsedTextNote = []; const pushPlainText = (index: number | undefined) => { if (index != null && pos !== index) { - const content = event.content.slice(pos, index); + const content = textNoteContent.slice(pos, index); const plainText: PlainText = { type: 'PlainText', content }; result.push(plainText); } @@ -94,34 +94,13 @@ const parseTextNote = (event: NostrEvent): ParsedTextNote => { result.push(url); } else if (match.groups?.idx) { const tagIndex = parseInt(match.groups.idx, 10); - const tag = event.tags[tagIndex]; - if (tag == null) return; - pushPlainText(index); - const tagName = tag[0]; - if (tagName === 'p') { - const mentionedUser: MentionedUser = { - type: 'MentionedUser', - tagIndex, - content: match[0], - pubkey: tag[1], - }; - result.push(mentionedUser); - } else if (tagName === 'e') { - const mention = eventWrapper(event) - .taggedEvents() - .find((ev) => ev.index === tagIndex); - - const mentionedEvent: MentionedEvent = { - type: 'MentionedEvent', - tagIndex, - content: match[0], - eventId: tag[1], - marker: mention?.marker, - }; - result.push(mentionedEvent); - } + result.push({ + type: 'TagReference', + tagIndex, + content: match[0], + }); } else if (match.groups?.mention) { pushPlainText(index); try { @@ -133,7 +112,7 @@ const parseTextNote = (event: NostrEvent): ParsedTextNote => { }; result.push(bech32Entity); } catch (e) { - console.error(`failed to parse Bech32 entity (NIP-19) but ignore this: ${match[0]}`); + console.warn(`failed to parse Bech32 entity (NIP-19): ${match[0]}`); pushPlainText(index + match[0].length); return; } @@ -150,8 +129,8 @@ const parseTextNote = (event: NostrEvent): ParsedTextNote => { pos = index + match[0].length; }); - if (pos !== event.content.length) { - const content = event.content.slice(pos); + if (pos !== textNoteContent.length) { + const content = textNoteContent.slice(pos); const plainText: PlainText = { type: 'PlainText', content }; result.push(plainText); } @@ -159,4 +138,39 @@ const parseTextNote = (event: NostrEvent): ParsedTextNote => { return result; }; +export const resolveTagReference = ( + { tagIndex, content }: TagReference, + event: NostrEvent, +): MentionedUser | MentionedEvent | null => { + const tag = event.tags[tagIndex]; + if (tag == null) return null; + + const tagName = tag[0]; + + if (tagName === 'p') { + return { + type: 'MentionedUser', + tagIndex, + content, + pubkey: tag[1], + } satisfies MentionedUser; + } + + if (tagName === 'e') { + const mention = eventWrapper(event) + .taggedEvents() + .find((ev) => ev.index === tagIndex); + + return { + type: 'MentionedEvent', + tagIndex, + content, + eventId: tag[1], + marker: mention?.marker, + } satisfies MentionedEvent; + } + + return null; +}; + export default parseTextNote;