import { EventFactory, Blueprints } from 'applesauce-factory' import { RelayPool } from 'applesauce-relay' import { IAccount } from 'applesauce-accounts' import { AddressPointer } from 'nostr-tools/nip19' import { NostrEvent } from 'nostr-tools' import { Helpers, IEventStore } from 'applesauce-core' import { RELAYS } from '../config/relays' import { Highlight } from '../types/highlights' import { UserSettings } from './settingsService' import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers' import { publishEvent } from './writeService' // Boris pubkey for zap splits // npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e' const { getHighlightText, getHighlightContext, getHighlightComment, getHighlightSourceEventPointer, getHighlightSourceAddressPointer, getHighlightSourceUrl, getHighlightAttributions } = Helpers const { HighlightBlueprint } = Blueprints /** * Creates and publishes a highlight event (NIP-84) * Supports both nostr-native articles and external URLs * Returns a Highlight object with relay tracking info for immediate UI updates */ export async function createHighlight( selectedText: string, source: NostrEvent | string, account: IAccount, relayPool: RelayPool, eventStore: IEventStore, contentForContext?: string, comment?: string, settings?: UserSettings ): Promise { if (!selectedText || !source) { throw new Error('Missing required data to create highlight') } // Create EventFactory with the account as signer const factory = new EventFactory({ signer: account }) let blueprintSource: NostrEvent | AddressPointer | string let context: string | undefined // Handle NostrEvent (article) source if (typeof source === 'object' && 'kind' in source) { blueprintSource = parseArticleCoordinate(source) context = extractContext(selectedText, source.content) } // Handle URL string source else { blueprintSource = source // Try to extract context from provided content if available if (contentForContext) { context = extractContext(selectedText, contentForContext) } } // Create highlight event using the blueprint const highlightEvent = await factory.create( HighlightBlueprint, selectedText, blueprintSource, context ? { comment, context } : comment ? { comment } : undefined ) // Update the alt tag to identify Boris as the creator const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt') if (altTagIndex !== -1) { highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. read.withboris.com'] } else { highlightEvent.tags.push(['alt', 'Highlight created by Boris. read.withboris.com']) } // Add p tag (author tag) for nostr-native content // This tags the original author so they can see highlights of their work if (typeof source === 'object' && 'kind' in source) { // Only add p tag if it doesn't already exist const hasPTag = highlightEvent.tags.some(tag => tag[0] === 'p' && tag[1] === source.pubkey) if (!hasPTag) { highlightEvent.tags.push(['p', source.pubkey]) } } // Add zap tags for nostr-native content (NIP-57 Appendix G) if (typeof source === 'object' && 'kind' in source) { // Migrate old settings format to new weight-based format if needed let highlighterWeight = settings?.zapSplitHighlighterWeight let borisWeight = settings?.zapSplitBorisWeight let authorWeight = settings?.zapSplitAuthorWeight const anySettings = settings as Record | undefined if (!highlighterWeight && anySettings && 'zapSplitPercentage' in anySettings) { highlighterWeight = anySettings.zapSplitPercentage as number authorWeight = 100 - (anySettings.zapSplitPercentage as number) } if (!borisWeight && anySettings && 'borisSupportPercentage' in anySettings) { borisWeight = anySettings.borisSupportPercentage as number } // Use defaults if still undefined highlighterWeight = highlighterWeight ?? 50 borisWeight = borisWeight ?? 2.1 authorWeight = authorWeight ?? 50 addZapTags(highlightEvent, account.pubkey, source, highlighterWeight, borisWeight, authorWeight) } // Sign the event console.log('[bunker] Signing highlight event...', { kind: highlightEvent.kind, tags: highlightEvent.tags.length }) const signedEvent = await factory.sign(highlightEvent) console.log('[bunker] ✅ Highlight signed successfully!', { id: signedEvent.id.slice(0, 8) }) // Use unified write service to store and publish await publishEvent(relayPool, eventStore, signedEvent) // Check current connection status for UI feedback const connectedRelays = Array.from(relayPool.relays.values()) .filter(relay => relay.connected) .map(relay => relay.url) const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url)) const expectedSuccessRelays = hasRemoteConnection ? RELAYS : RELAYS.filter(isLocalRelay) const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays) // Convert to Highlight with relay tracking info and return IMMEDIATELY const highlight = eventToHighlight(signedEvent) highlight.publishedRelays = expectedSuccessRelays highlight.isLocalOnly = isLocalOnly highlight.isOfflineCreated = isLocalOnly return highlight } /** * Parse article coordinate to create address pointer */ function parseArticleCoordinate(article: NostrEvent): AddressPointer { // Try to get identifier from article tags const identifier = article.tags.find(tag => tag[0] === 'd')?.[1] || '' return { kind: article.kind, pubkey: article.pubkey, identifier, relays: [] // Optional relays hint } } /** * Extracts context for a highlight by finding the previous and next sentences * in the same paragraph as the selected text */ function extractContext(selectedText: string, articleContent: string): string | undefined { if (!selectedText || !articleContent) return undefined // Find the position of the selected text in the article const selectedIndex = articleContent.indexOf(selectedText) if (selectedIndex === -1) return undefined // Split content into paragraphs (by double newlines or single newlines) const paragraphs = articleContent.split(/\n\n+/) // Find which paragraph contains the selected text let currentPos = 0 let containingParagraph: string | undefined for (const paragraph of paragraphs) { const paragraphEnd = currentPos + paragraph.length if (selectedIndex >= currentPos && selectedIndex < paragraphEnd) { containingParagraph = paragraph break } currentPos = paragraphEnd + 2 // Account for the double newline } if (!containingParagraph) return undefined // Split paragraph into sentences (basic sentence splitting) // This regex splits on periods, exclamation marks, or question marks followed by space or end of string const sentences = containingParagraph.split(/([.!?]+\s+)/).filter(s => s.trim().length > 0) // Reconstruct sentences properly by joining sentence text with punctuation const reconstructedSentences: string[] = [] for (let i = 0; i < sentences.length; i++) { if (sentences[i].match(/^[.!?]+\s*$/)) { // This is punctuation, attach it to previous sentence if (reconstructedSentences.length > 0) { reconstructedSentences[reconstructedSentences.length - 1] += sentences[i] } } else { reconstructedSentences.push(sentences[i]) } } // Find which sentence contains the selected text let selectedSentenceIndex = -1 for (let i = 0; i < reconstructedSentences.length; i++) { if (reconstructedSentences[i].includes(selectedText)) { selectedSentenceIndex = i break } } if (selectedSentenceIndex === -1) return undefined // Build context from previous and next sentences const contextParts: string[] = [] // Add previous sentence if it exists if (selectedSentenceIndex > 0) { contextParts.push(reconstructedSentences[selectedSentenceIndex - 1].trim()) } // Add the selected sentence itself contextParts.push(reconstructedSentences[selectedSentenceIndex].trim()) // Add next sentence if it exists if (selectedSentenceIndex < reconstructedSentences.length - 1) { contextParts.push(reconstructedSentences[selectedSentenceIndex + 1].trim()) } // Only return context if we have more than just the selected sentence return contextParts.length > 1 ? contextParts.join(' ') : undefined } /** * Adds zap tags to a highlight event for split payments (NIP-57 Appendix G) * Respects existing zap tags in the source event (author group) * @param event The highlight event to add zap tags to (can be EventTemplate or NostrEvent) * @param highlighterPubkey The pubkey of the user creating the highlight * @param sourceEvent The source event (may contain existing zap tags) * @param highlighterWeight Weight to give to the highlighter (default 50) * @param borisWeight Weight to give to Boris (default 2.1) * @param authorWeight Weight to give to author(s) (default 50) */ function addZapTags( event: { tags: string[][] }, highlighterPubkey: string, sourceEvent: NostrEvent, highlighterWeight: number = 50, borisWeight: number = 2.1, authorWeight: number = 50 ): void { // Use a reliable relay for zap metadata lookup (first non-local relay) const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0] // Extract existing zap tags from source event (the "author group") const existingZapTags = sourceEvent.tags.filter(tag => tag[0] === 'zap') // Add zap tag for the highlighter if (highlighterWeight > 0) { event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()]) } // Add zap tag for Boris (if weight > 0 and Boris is not the highlighter) if (borisWeight > 0 && BORIS_PUBKEY !== highlighterPubkey) { event.tags.push(['zap', BORIS_PUBKEY, zapRelay, borisWeight.toFixed(1)]) } if (existingZapTags.length > 0 && authorWeight > 0) { // Calculate total weight from existing zap tags const totalExistingWeight = existingZapTags.reduce((sum, tag) => { const weight = parseFloat(tag[3] || '1') return sum + weight }, 0) // Add proportionally adjusted zap tags for each existing author // Don't add the highlighter or Boris again if they're already in the author group for (const zapTag of existingZapTags) { const authorPubkey = zapTag[1] // Skip if this is the highlighter or Boris (they already have their shares) if (authorPubkey === highlighterPubkey || authorPubkey === BORIS_PUBKEY) continue const originalWeight = parseFloat(zapTag[3] || '1') const originalRelay = zapTag[2] || zapRelay // Calculate proportional weight: (original weight / total weight) * author group weight const adjustedWeight = (originalWeight / totalExistingWeight) * authorWeight // Only add if weight is greater than 0 if (adjustedWeight > 0) { event.tags.push(['zap', authorPubkey, originalRelay, adjustedWeight.toFixed(1)]) } } } else if (authorWeight > 0) { // No existing zap tags, give full author weight to source author // Add zap tag for the original author (only if different from highlighter and Boris) if (sourceEvent.pubkey !== highlighterPubkey && sourceEvent.pubkey !== BORIS_PUBKEY) { event.tags.push(['zap', sourceEvent.pubkey, zapRelay, authorWeight.toFixed(1)]) } } } /** * Converts a NostrEvent to a Highlight object for immediate UI display */ export function eventToHighlight(event: NostrEvent): Highlight { const highlightText = getHighlightText(event) const context = getHighlightContext(event) const comment = getHighlightComment(event) const sourceEventPointer = getHighlightSourceEventPointer(event) const sourceAddressPointer = getHighlightSourceAddressPointer(event) const sourceUrl = getHighlightSourceUrl(event) const attributions = getHighlightAttributions(event) const author = attributions.find(a => a.role === 'author')?.pubkey const eventReference = sourceEventPointer?.id || (sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined) return { id: event.id, pubkey: event.pubkey, created_at: event.created_at, content: highlightText, tags: event.tags, eventReference, urlReference: sourceUrl, author, context, comment } }