14 changed files with 387 additions and 41 deletions
@ -0,0 +1,53 @@
@@ -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 @@
@@ -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