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/__ (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 ? `[![${params.authorName}](${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 { 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 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 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): string { return typeof tags.title === 'string' && tags.title.length > 0 ? tags.title : 'Présentation' } function resolvePresentationPreview(tags: ReturnType, 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): string { if (typeof profileData?.presentation === 'string') { return profileData.presentation } return typeof tags.description === 'string' ? tags.description : '' } function resolvePresentationContentDescriptionRaw(profileData: PresentationProfileData | null, tags: ReturnType): 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 ): 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 ): 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): 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): string { if (typeof profileData?.pictureUrl === 'string') { return profileData.pictureUrl } return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : '' } function resolvePresentationBannerUrl(profileData: PresentationProfileData | null, tags: ReturnType): 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): 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 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 { // 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((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 => { 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 => { // 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 => { // 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?.() }) }