14 changed files with 387 additions and 41 deletions
@ -0,0 +1,53 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { |
||||||
|
findTrailingStringifiedNostrEvent, |
||||||
|
isNostrEventJson, |
||||||
|
stripTrailingStringifiedNostrEvent |
||||||
|
} from './nostr-event-json' |
||||||
|
|
||||||
|
function event(overrides: Partial<Event> = {}): Event { |
||||||
|
return { |
||||||
|
id: 'a'.repeat(64), |
||||||
|
pubkey: 'b'.repeat(64), |
||||||
|
created_at: 1000, |
||||||
|
kind: 1, |
||||||
|
tags: [['p', 'c'.repeat(64)]], |
||||||
|
content: 'original note', |
||||||
|
sig: 'd'.repeat(128), |
||||||
|
...overrides |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('nostr event JSON helpers', () => { |
||||||
|
it('recognizes serialized nostr events', () => { |
||||||
|
expect(isNostrEventJson(event())).toBe(true) |
||||||
|
expect(isNostrEventJson({ ...event(), id: 'not-hex' })).toBe(false) |
||||||
|
expect(isNostrEventJson({ ...event(), tags: [['p', 1]] })).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('extracts a whole stringified event', () => { |
||||||
|
const target = event() |
||||||
|
const match = findTrailingStringifiedNostrEvent(JSON.stringify(target)) |
||||||
|
|
||||||
|
expect(match?.event.id).toBe(target.id) |
||||||
|
expect(match?.textBefore).toBe('') |
||||||
|
}) |
||||||
|
|
||||||
|
it('extracts trailing event JSON after quote text', () => { |
||||||
|
const target = event({ content: 'quoted target' }) |
||||||
|
const content = `This is my comment before the boost.\n\n${JSON.stringify(target)}` |
||||||
|
const match = findTrailingStringifiedNostrEvent(content) |
||||||
|
|
||||||
|
expect(match?.event.content).toBe('quoted target') |
||||||
|
expect(match?.textBefore).toBe('This is my comment before the boost.') |
||||||
|
expect(stripTrailingStringifiedNostrEvent(content)).toBe('This is my comment before the boost.') |
||||||
|
}) |
||||||
|
|
||||||
|
it('leaves ordinary JSON alone', () => { |
||||||
|
const content = 'Here is config {"theme":"dark"}' |
||||||
|
|
||||||
|
expect(findTrailingStringifiedNostrEvent(content)).toBeNull() |
||||||
|
expect(stripTrailingStringifiedNostrEvent(content)).toBe(content) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
const HEX_64_RE = /^[0-9a-f]{64}$/i |
||||||
|
const HEX_SIG_RE = /^[0-9a-f]{128}$/i |
||||||
|
|
||||||
|
export type StringifiedNostrEventMatch = { |
||||||
|
event: Event |
||||||
|
textBefore: string |
||||||
|
jsonText: string |
||||||
|
} |
||||||
|
|
||||||
|
function isStringArrayArray(value: unknown): value is string[][] { |
||||||
|
return ( |
||||||
|
Array.isArray(value) && |
||||||
|
value.every((tag) => Array.isArray(tag) && tag.every((part) => typeof part === 'string')) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export function isNostrEventJson(value: unknown): value is Event { |
||||||
|
if (!value || typeof value !== 'object') return false |
||||||
|
const event = value as Partial<Event> |
||||||
|
return ( |
||||||
|
typeof event.id === 'string' && |
||||||
|
HEX_64_RE.test(event.id) && |
||||||
|
typeof event.pubkey === 'string' && |
||||||
|
HEX_64_RE.test(event.pubkey) && |
||||||
|
typeof event.created_at === 'number' && |
||||||
|
Number.isFinite(event.created_at) && |
||||||
|
typeof event.kind === 'number' && |
||||||
|
Number.isFinite(event.kind) && |
||||||
|
isStringArrayArray(event.tags) && |
||||||
|
typeof event.content === 'string' && |
||||||
|
typeof event.sig === 'string' && |
||||||
|
HEX_SIG_RE.test(event.sig) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function parseNostrEventJson(raw: string): Event | null { |
||||||
|
try { |
||||||
|
const parsed = JSON.parse(raw) |
||||||
|
return isNostrEventJson(parsed) ? parsed : null |
||||||
|
} catch { |
||||||
|
return null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Some clients append a full serialized event after quote/repost text. Treat a trailing event JSON |
||||||
|
* object as structured data instead of showing it as prose. |
||||||
|
*/ |
||||||
|
export function findTrailingStringifiedNostrEvent(content: string): StringifiedNostrEventMatch | null { |
||||||
|
const trimmed = content.trimEnd() |
||||||
|
if (!trimmed) return null |
||||||
|
|
||||||
|
const whole = parseNostrEventJson(trimmed) |
||||||
|
if (whole) { |
||||||
|
return { |
||||||
|
event: whole, |
||||||
|
textBefore: '', |
||||||
|
jsonText: trimmed |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let start = trimmed.lastIndexOf('{') |
||||||
|
while (start >= 0) { |
||||||
|
const jsonText = trimmed.slice(start) |
||||||
|
const event = parseNostrEventJson(jsonText) |
||||||
|
if (event) { |
||||||
|
return { |
||||||
|
event, |
||||||
|
textBefore: trimmed.slice(0, start).trimEnd(), |
||||||
|
jsonText |
||||||
|
} |
||||||
|
} |
||||||
|
start = trimmed.lastIndexOf('{', start - 1) |
||||||
|
} |
||||||
|
|
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
export function stripTrailingStringifiedNostrEvent(content: string): string { |
||||||
|
return findTrailingStringifiedNostrEvent(content)?.textBefore ?? content |
||||||
|
} |
||||||
Loading…
Reference in new issue