story-research-zapwall/lib/articlePublisherHelpersPresentation.ts
2026-01-06 14:17:55 +01:00

305 lines
9.8 KiB
TypeScript

import { type Event } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import type { AuthorPresentationDraft } from './articlePublisher'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
import { generateAuthorHashId } from './hashIdGenerator'
import { generateObjectUrl, buildObjectId, parseObjectId } from './urlGenerator'
import { getLatestVersion } from './versionManager'
import { objectCache } from './objectCache'
export async function buildPresentationEvent(
draft: AuthorPresentationDraft,
authorPubkey: string,
authorName: string,
category: 'sciencefiction' | 'research' = 'sciencefiction',
version: number = 0,
index: number = 0
) {
// Extract presentation and contentDescription from draft.content
// Format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
const separator = '\n\n---\n\nDescription du contenu :\n'
const separatorIndex = draft.content.indexOf(separator)
const presentation = separatorIndex !== -1 ? draft.content.substring(0, separatorIndex) : draft.presentation
const contentDescription = separatorIndex !== -1 ? draft.content.substring(separatorIndex + separator.length) : draft.contentDescription
// Generate hash ID from author data first (needed for URL)
const hashId = await generateAuthorHashId({
pubkey: authorPubkey,
authorName,
presentation,
contentDescription,
mainnetAddress: draft.mainnetAddress ?? undefined,
pictureUrl: draft.pictureUrl ?? undefined,
category,
})
// Build URL: https://zapwall.fr/author/<hash>_<index>_<version> (using hash ID)
const profileUrl = generateObjectUrl('author', hashId, index, version)
// Encode pubkey to npub (for metadata JSON)
const npub = nip19.npubEncode(authorPubkey)
// Build visible content message
// If picture exists, use it as preview image for the link (markdown format)
// Note: The image will display at full size in most Nostr clients, not as a thumbnail
const linkWithPreview = draft.pictureUrl
? `[![${authorName}](${draft.pictureUrl})](${profileUrl})`
: profileUrl
const visibleContent = [
'Nouveau profil publié sur zapwall.fr',
linkWithPreview,
`Présentation personnelle : ${presentation}`,
`Description de votre contenu : ${contentDescription}`,
`Adresse Bitcoin mainnet (pour le sponsoring) : ${draft.mainnetAddress}`,
].join('\n')
// Build profile JSON for metadata (stored in tag, not in content)
const profileJson = JSON.stringify({
authorName,
npub,
pubkey: authorPubkey,
presentation,
contentDescription,
mainnetAddress: draft.mainnetAddress,
pictureUrl: draft.pictureUrl,
category,
url: profileUrl,
version,
index,
})
// Build tags (profile JSON is in tag, not in content)
const tags = buildTags({
type: 'author',
category,
id: hashId,
service: PLATFORM_SERVICE,
version,
hidden: false,
paywall: false,
title: draft.title,
preview: draft.preview,
mainnetAddress: draft.mainnetAddress,
totalSponsoring: 0,
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
})
// Add JSON metadata as a tag (not in visible content)
tags.push(['json', profileJson])
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags,
content: visibleContent,
}
}
export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const tags = extractTagsFromEvent(event)
// Check if it's an author type (tag is 'author' in English)
if (tags.type !== 'author') {
return null
}
// Try to extract profile JSON from tag first (new format)
let profileData: {
authorName?: string
presentation?: string
contentDescription?: string
mainnetAddress?: string
pictureUrl?: string
category?: string
} | null = null
if (tags.json) {
try {
profileData = JSON.parse(tags.json)
} catch (e) {
console.error('Error parsing JSON from tag:', e)
}
}
// Fallback to content format (for backward compatibility with old notes)
if (!profileData) {
// Try invisible format (with zero-width characters)
const invisibleJsonMatch = event.content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s)
if (invisibleJsonMatch?.[1]) {
try {
// Remove zero-width characters from JSON
const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim()
profileData = JSON.parse(cleanedJson)
} catch (e) {
console.error('Error parsing profile JSON from invisible content:', e)
}
}
// Fallback to visible format in content
if (!profileData) {
const jsonMatch = event.content.match(/\[Metadata JSON\]\n(.+)$/s)
if (jsonMatch?.[1]) {
try {
profileData = JSON.parse(jsonMatch[1].trim())
} catch (e) {
console.error('Error parsing profile JSON from content:', e)
}
}
}
}
// Map tag category to article category
const articleCategory = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined
// Extract hash, version, index from id tag or parse it
let hash: string
let version = tags.version ?? 0
let index = 0
if (tags.id) {
const parsed = parseObjectId(tags.id)
if (parsed.hash) {
hash = parsed.hash
version = parsed.version ?? version
index = parsed.index ?? index
} else {
// If id is just a hash, use it directly
hash = tags.id
}
} else {
// Generate hash from author data
hash = await generateAuthorHashId({
pubkey: event.pubkey,
authorName: profileData?.authorName ?? '',
presentation: profileData?.presentation ?? '',
contentDescription: profileData?.contentDescription ?? '',
mainnetAddress: profileData?.mainnetAddress ?? tags.mainnetAddress ?? undefined,
pictureUrl: profileData?.pictureUrl ?? tags.pictureUrl ?? undefined,
category: profileData?.category ?? tags.category ?? 'sciencefiction',
})
}
const id = buildObjectId(hash, index, version)
const result: import('@/types/nostr').AuthorPresentationArticle = {
id,
hash,
version,
index,
pubkey: event.pubkey,
title: tags.title ?? 'Présentation',
preview: tags.preview ?? event.content.substring(0, 200),
content: event.content,
description: profileData?.presentation ?? tags.description ?? '', // Required field
contentDescription: profileData?.contentDescription ?? tags.description ?? '', // Required field
thumbnailUrl: profileData?.pictureUrl ?? tags.pictureUrl ?? '', // Required field
createdAt: event.created_at,
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: profileData?.mainnetAddress ?? tags.mainnetAddress ?? '',
totalSponsoring: tags.totalSponsoring ?? 0,
originalCategory: articleCategory ?? 'science-fiction', // Store original category for filtering
}
// Add bannerUrl if available
if (profileData?.pictureUrl !== undefined && profileData?.pictureUrl !== null) {
result.bannerUrl = profileData.pictureUrl
} else if (tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string') {
result.bannerUrl = tags.pictureUrl
}
return result
}
export async function fetchAuthorPresentationFromPool(
pool: SimplePoolWithSub,
pubkey: string
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
// Check cache first
const cached = await objectCache.getAuthorByPubkey(pubkey)
if (cached) {
return cached
}
const filters = [
{
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
service: PLATFORM_SERVICE,
}),
since: MIN_EVENT_DATE,
limit: 100, // Get all versions to find the latest
},
]
return new Promise((resolve) => {
let resolved = false
const relayUrl = getPrimaryRelaySync()
const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters)
const events: Event[] = []
const finalize = async (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
// Cache the result if found
if (value && events.length > 0) {
const event = events.find(e => e.id === value.id) || events[0]
if (event) {
const tags = extractTagsFromEvent(event)
if (value.hash) {
await objectCache.set('author', value.hash, event, value, tags.version ?? 0, tags.hidden, value.index)
}
}
}
resolve(value)
}
sub.on('event', (event: Event) => {
// Collect all events first
const tags = extractTagsFromEvent(event)
if (tags.type === 'author' && !tags.hidden) {
events.push(event)
}
})
sub.on('eose', async () => {
// Get the latest version from all collected events
const latestEvent = getLatestVersion(events)
if (latestEvent) {
const parsed = await parsePresentationEvent(latestEvent)
if (parsed) {
await finalize(parsed)
return
}
}
await finalize(null)
})
setTimeout(async () => {
// Get the latest version from all collected events
const latestEvent = getLatestVersion(events)
if (latestEvent) {
const parsed = await parsePresentationEvent(latestEvent)
if (parsed) {
await finalize(parsed)
return
}
}
await finalize(null)
}, 5000).unref?.()
})
}