532 lines
18 KiB
TypeScript
532 lines
18 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 { createSubscription } 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'
|
|
|
|
interface BuildPresentationEventParams {
|
|
draft: AuthorPresentationDraft
|
|
authorPubkey: string
|
|
authorName: string
|
|
category?: 'sciencefiction' | 'research'
|
|
version?: number
|
|
index?: number
|
|
}
|
|
|
|
export async function buildPresentationEvent(
|
|
params: BuildPresentationEventParams
|
|
): Promise<{
|
|
kind: 1
|
|
created_at: number
|
|
tags: string[][]
|
|
content: string
|
|
}> {
|
|
const category = params.category ?? 'sciencefiction'
|
|
const version = params.version ?? 0
|
|
const index = params.index ?? 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 = params.draft.content.indexOf(separator)
|
|
const presentation = separatorIndex !== -1 ? params.draft.content.substring(0, separatorIndex) : params.draft.presentation
|
|
let contentDescription = separatorIndex !== -1 ? params.draft.content.substring(separatorIndex + separator.length) : params.draft.contentDescription
|
|
|
|
// Remove Bitcoin address from contentDescription if present (should not be visible in note content)
|
|
// Remove lines matching "Adresse Bitcoin mainnet (pour le sponsoring) : ..."
|
|
if (contentDescription) {
|
|
contentDescription = contentDescription
|
|
.split('\n')
|
|
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
|
|
.join('\n')
|
|
.trim()
|
|
}
|
|
|
|
// Generate hash ID from author data first (needed for URL)
|
|
const hashId = await generateAuthorHashId({
|
|
pubkey: params.authorPubkey,
|
|
authorName: params.authorName,
|
|
presentation,
|
|
contentDescription,
|
|
mainnetAddress: params.draft.mainnetAddress ?? undefined,
|
|
pictureUrl: params.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(params.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 {draft} = params
|
|
const linkWithPreview = draft.pictureUrl
|
|
? `[](${profileUrl})`
|
|
: profileUrl
|
|
|
|
const visibleContent = [
|
|
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
|
|
linkWithPreview,
|
|
`Présentation personnelle : ${presentation}`,
|
|
...(contentDescription ? [`Description de votre contenu : ${contentDescription}`] : []),
|
|
].join('\n')
|
|
|
|
// Build profile JSON for metadata (stored in tag, not in content)
|
|
const profileJson = JSON.stringify({
|
|
authorName: params.authorName,
|
|
npub,
|
|
pubkey: params.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,
|
|
...(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
|
|
}
|
|
|
|
const profileData = readPresentationProfileData(tags.json, event.content)
|
|
const originalCategory = mapTagCategoryToOriginalCategory(tags.category)
|
|
const { hash, version, index } = await resolvePresentationIdParts({ tags, event, profileData })
|
|
const id = buildObjectId(hash, index, version)
|
|
|
|
return buildPresentationArticle({
|
|
id,
|
|
hash,
|
|
version,
|
|
index,
|
|
event,
|
|
tags,
|
|
profileData,
|
|
originalCategory,
|
|
})
|
|
}
|
|
|
|
type PresentationProfileData = {
|
|
authorName?: string
|
|
presentation?: string
|
|
contentDescription?: string
|
|
mainnetAddress?: string
|
|
pictureUrl?: string
|
|
category?: string
|
|
}
|
|
|
|
function readPresentationProfileData(jsonTag: string | undefined, content: string): PresentationProfileData | null {
|
|
if (jsonTag) {
|
|
return parsePresentationProfileJson(jsonTag)
|
|
}
|
|
|
|
// Backward compatibility: invisible format (with zero-width characters)
|
|
const invisibleJsonMatch = content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s)
|
|
if (invisibleJsonMatch?.[1]) {
|
|
try {
|
|
const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim()
|
|
return parsePresentationProfileJson(cleanedJson)
|
|
} catch (invisibleJsonError) {
|
|
console.error('Error parsing profile JSON from invisible content:', invisibleJsonError)
|
|
}
|
|
}
|
|
|
|
// Backward compatibility: visible format
|
|
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
|
|
if (jsonMatch?.[1]) {
|
|
return parsePresentationProfileJson(jsonMatch[1].trim())
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function mapTagCategoryToOriginalCategory(category: unknown): 'science-fiction' | 'scientific-research' | undefined {
|
|
if (category === 'sciencefiction') {
|
|
return 'science-fiction'
|
|
}
|
|
if (category === 'research') {
|
|
return 'scientific-research'
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
async function resolvePresentationIdParts(params: {
|
|
tags: ReturnType<typeof extractTagsFromEvent>
|
|
event: Event
|
|
profileData: PresentationProfileData | null
|
|
}): Promise<{ hash: string; version: number; index: number }> {
|
|
const version = typeof params.tags.version === 'number' ? params.tags.version : 0
|
|
const index = 0
|
|
|
|
const fromIdTag = resolvePresentationIdPartsFromIdTag(params.tags.id, version, index)
|
|
if (fromIdTag) {
|
|
return fromIdTag
|
|
}
|
|
|
|
const mainnetAddress = resolvePresentationMainnetAddressCandidate(params.profileData, params.tags)
|
|
const pictureUrl = resolvePresentationPictureUrlCandidate(params.profileData, params.tags)
|
|
|
|
const hash = await generateAuthorHashId({
|
|
pubkey: params.event.pubkey,
|
|
authorName: resolveOptionalString(params.profileData?.authorName),
|
|
presentation: resolveOptionalString(params.profileData?.presentation),
|
|
contentDescription: resolveOptionalString(params.profileData?.contentDescription),
|
|
mainnetAddress,
|
|
pictureUrl,
|
|
category: resolvePresentationHashCategory(params.profileData, params.tags),
|
|
})
|
|
|
|
return { hash, version, index }
|
|
}
|
|
|
|
function resolvePresentationIdPartsFromIdTag(
|
|
idTag: string | undefined,
|
|
defaultVersion: number,
|
|
defaultIndex: number
|
|
): { hash: string; version: number; index: number } | undefined {
|
|
if (!idTag) {
|
|
return undefined
|
|
}
|
|
|
|
const parsed = parseObjectId(idTag)
|
|
if (parsed.hash) {
|
|
return {
|
|
hash: parsed.hash,
|
|
version: parsed.version ?? defaultVersion,
|
|
index: parsed.index ?? defaultIndex,
|
|
}
|
|
}
|
|
|
|
return { hash: idTag, version: defaultVersion, index: defaultIndex }
|
|
}
|
|
|
|
function buildPresentationArticle(params: {
|
|
id: string
|
|
hash: string
|
|
version: number
|
|
index: number
|
|
event: Event
|
|
tags: ReturnType<typeof extractTagsFromEvent>
|
|
profileData: PresentationProfileData | null
|
|
originalCategory: 'science-fiction' | 'scientific-research' | undefined
|
|
}): import('@/types/nostr').AuthorPresentationArticle {
|
|
const description = resolvePresentationDescription(params.profileData, params.tags)
|
|
const contentDescription = sanitizePresentationContentDescription(resolvePresentationContentDescriptionRaw(params.profileData, params.tags))
|
|
const thumbnailUrl = resolvePresentationThumbnailUrl(params.profileData, params.tags)
|
|
const mainnetAddress = resolvePresentationMainnetAddress(params.profileData, params.tags)
|
|
const bannerUrl = resolvePresentationBannerUrl(params.profileData, params.tags)
|
|
const title = resolvePresentationTitle(params.tags)
|
|
const preview = resolvePresentationPreview(params.tags, params.event.content)
|
|
|
|
return {
|
|
id: params.id,
|
|
hash: params.hash,
|
|
version: params.version,
|
|
index: params.index,
|
|
pubkey: params.event.pubkey,
|
|
title,
|
|
preview,
|
|
content: params.event.content,
|
|
description,
|
|
contentDescription,
|
|
thumbnailUrl,
|
|
createdAt: params.event.created_at,
|
|
zapAmount: 0,
|
|
paid: true,
|
|
category: 'author-presentation',
|
|
isPresentation: true,
|
|
mainnetAddress,
|
|
totalSponsoring: 0,
|
|
originalCategory: params.originalCategory ?? 'science-fiction',
|
|
...(bannerUrl ? { bannerUrl } : {}),
|
|
}
|
|
}
|
|
|
|
function resolvePresentationTitle(tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
return typeof tags.title === 'string' && tags.title.length > 0 ? tags.title : 'Présentation'
|
|
}
|
|
|
|
function resolvePresentationPreview(tags: ReturnType<typeof extractTagsFromEvent>, content: string): string {
|
|
if (typeof tags.preview === 'string' && tags.preview.length > 0) {
|
|
return tags.preview
|
|
}
|
|
return content.substring(0, 200)
|
|
}
|
|
|
|
function resolvePresentationDescription(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
if (typeof profileData?.presentation === 'string') {
|
|
return profileData.presentation
|
|
}
|
|
return typeof tags.description === 'string' ? tags.description : ''
|
|
}
|
|
|
|
function resolvePresentationContentDescriptionRaw(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
if (typeof profileData?.contentDescription === 'string') {
|
|
return profileData.contentDescription
|
|
}
|
|
return typeof tags.description === 'string' ? tags.description : ''
|
|
}
|
|
|
|
function resolveOptionalString(value: unknown): string {
|
|
return typeof value === 'string' ? value : ''
|
|
}
|
|
|
|
function resolvePresentationMainnetAddressCandidate(
|
|
profileData: PresentationProfileData | null,
|
|
tags: ReturnType<typeof extractTagsFromEvent>
|
|
): string | undefined {
|
|
if (typeof profileData?.mainnetAddress === 'string') {
|
|
return profileData.mainnetAddress
|
|
}
|
|
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : undefined
|
|
}
|
|
|
|
function resolvePresentationPictureUrlCandidate(
|
|
profileData: PresentationProfileData | null,
|
|
tags: ReturnType<typeof extractTagsFromEvent>
|
|
): string | undefined {
|
|
if (typeof profileData?.pictureUrl === 'string') {
|
|
return profileData.pictureUrl
|
|
}
|
|
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
|
|
}
|
|
|
|
function resolvePresentationHashCategory(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
if (typeof profileData?.category === 'string') {
|
|
return profileData.category
|
|
}
|
|
return typeof tags.category === 'string' ? tags.category : 'sciencefiction'
|
|
}
|
|
|
|
function sanitizePresentationContentDescription(raw: string): string {
|
|
return raw
|
|
.split('\n')
|
|
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
|
|
.join('\n')
|
|
.trim()
|
|
}
|
|
|
|
function resolvePresentationThumbnailUrl(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
if (typeof profileData?.pictureUrl === 'string') {
|
|
return profileData.pictureUrl
|
|
}
|
|
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : ''
|
|
}
|
|
|
|
function resolvePresentationBannerUrl(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string | undefined {
|
|
if (typeof profileData?.pictureUrl === 'string' && profileData.pictureUrl.length > 0) {
|
|
return profileData.pictureUrl
|
|
}
|
|
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
|
|
}
|
|
|
|
function resolvePresentationMainnetAddress(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
const fromProfile = profileData?.mainnetAddress
|
|
if (typeof fromProfile === 'string') {
|
|
return fromProfile
|
|
}
|
|
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : ''
|
|
}
|
|
|
|
function parsePresentationProfileJson(json: string): {
|
|
authorName?: string
|
|
presentation?: string
|
|
contentDescription?: string
|
|
mainnetAddress?: string
|
|
pictureUrl?: string
|
|
category?: string
|
|
} | null {
|
|
try {
|
|
const parsed: unknown = JSON.parse(json)
|
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
return null
|
|
}
|
|
|
|
const obj = parsed as Record<string, unknown>
|
|
const result: {
|
|
authorName?: string
|
|
presentation?: string
|
|
contentDescription?: string
|
|
mainnetAddress?: string
|
|
pictureUrl?: string
|
|
category?: string
|
|
} = {}
|
|
|
|
if (typeof obj.authorName === 'string') {
|
|
result.authorName = obj.authorName
|
|
}
|
|
if (typeof obj.presentation === 'string') {
|
|
result.presentation = obj.presentation
|
|
}
|
|
if (typeof obj.contentDescription === 'string') {
|
|
result.contentDescription = obj.contentDescription
|
|
}
|
|
if (typeof obj.mainnetAddress === 'string') {
|
|
result.mainnetAddress = obj.mainnetAddress
|
|
}
|
|
if (typeof obj.pictureUrl === 'string') {
|
|
result.pictureUrl = obj.pictureUrl
|
|
}
|
|
if (typeof obj.category === 'string') {
|
|
result.category = obj.category
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
console.error('Error parsing presentation profile JSON:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function fetchAuthorPresentationFromPool(
|
|
pool: SimplePoolWithSub,
|
|
pubkey: string
|
|
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
|
// Check cache first - this is the primary source
|
|
const cached = await objectCache.getAuthorByPubkey(pubkey)
|
|
if (cached) {
|
|
// Calculate totalSponsoring from cache
|
|
const { getAuthorSponsoring } = await import('./sponsoring')
|
|
cached.totalSponsoring = await getAuthorSponsoring(pubkey)
|
|
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<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
|
|
let resolved = false
|
|
const relayUrl = getPrimaryRelaySync()
|
|
const sub = createSubscription(pool, [relayUrl], filters)
|
|
|
|
const events: Event[] = []
|
|
|
|
const finalize = async (value: import('@/types/nostr').AuthorPresentationArticle | null): Promise<void> => {
|
|
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) {
|
|
// Calculate totalSponsoring from cache before storing
|
|
const { getAuthorSponsoring } = await import('./sponsoring')
|
|
const totalSponsoring = await getAuthorSponsoring(value.pubkey)
|
|
const cachedValue: import('@/types/nostr').AuthorPresentationArticle = {
|
|
...value,
|
|
totalSponsoring,
|
|
}
|
|
const { writeObjectToCache } = await import('./helpers/writeObjectHelper')
|
|
await writeObjectToCache({
|
|
objectType: 'author',
|
|
hash: value.hash,
|
|
event,
|
|
parsed: cachedValue,
|
|
version: tags.version,
|
|
hidden: tags.hidden,
|
|
index: value.index,
|
|
})
|
|
|
|
resolve(cachedValue)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
resolve(value)
|
|
}
|
|
|
|
sub.on('event', (event: Event): void => {
|
|
// Collect all events first
|
|
const tags = extractTagsFromEvent(event)
|
|
if (tags.type === 'author' && !tags.hidden) {
|
|
events.push(event)
|
|
}
|
|
})
|
|
|
|
sub.on('eose', (): void => {
|
|
void (async (): Promise<void> => {
|
|
// 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)
|
|
})()
|
|
})
|
|
// Reduced timeout for faster feedback when cache is empty
|
|
setTimeout((): void => {
|
|
void (async (): Promise<void> => {
|
|
// 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)
|
|
})()
|
|
}, 2000).unref?.()
|
|
})
|
|
}
|