Files
boris/src/services/highlightCreationService.ts
Gigi 2c94c1e3f0 fix: remove unused variables to resolve lint errors
- Remove unused relayNames variable from HighlightItem.tsx
- Remove unused failedRelays variable from highlightCreationService.ts
- All linting and type checks now pass
2025-10-31 00:18:58 +01:00

431 lines
15 KiB
TypeScript

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 } from '../utils/helpers'
import { setHighlightMetadata } from './highlightEventProcessor'
// Boris pubkey for zap splits
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
// Extended event type with highlight metadata
interface HighlightEvent extends NostrEvent {
__highlightProps?: {
publishedRelays?: string[]
isLocalOnly?: boolean
isSyncing?: boolean
}
}
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<Highlight> {
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.signer })
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<string, unknown> | 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
const signedEvent = await factory.sign(highlightEvent)
// Initialize custom properties on the event (will be updated after publishing)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: [],
isLocalOnly: false,
isSyncing: false
}
// Get only connected relays to avoid long timeouts
const connectedRelays = Array.from(relayPool.relays.values())
.filter(relay => relay.connected)
.map(relay => relay.url)
let publishResponses: { ok: boolean; message?: string; from: string }[] = []
let isLocalOnly = false
try {
// Publish only to connected relays to avoid long timeouts
if (connectedRelays.length === 0) {
isLocalOnly = true
} else {
publishResponses = await relayPool.publish(connectedRelays, signedEvent)
}
// Determine which relays successfully accepted the event
const successfulRelays = publishResponses
.filter(response => response.ok)
.map(response => response.from)
const successfulLocalRelays = successfulRelays.filter(url => isLocalRelay(url))
const successfulRemoteRelays = successfulRelays.filter(url => !isLocalRelay(url))
// isLocalOnly is true if only local relays accepted the event
isLocalOnly = successfulLocalRelays.length > 0 && successfulRemoteRelays.length === 0
// Handle case when no relays were connected
const successfulRelaysList = publishResponses.length > 0
? publishResponses
.filter(response => response.ok)
.map(response => response.from)
: []
// Store metadata in cache (persists across EventStore serialization)
setHighlightMetadata(signedEvent.id, {
publishedRelays: successfulRelaysList,
isLocalOnly,
isSyncing: false
})
// Also update the event with the actual properties (for backwards compatibility)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: successfulRelaysList,
isLocalOnly,
isSyncing: false
}
// Store the event in EventStore AFTER updating with final properties
eventStore.add(signedEvent)
// Mark for offline sync if we're in local-only mode
if (isLocalOnly) {
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
markEventAsOfflineCreated(signedEvent.id)
}
} catch (error) {
console.error('❌ [HIGHLIGHT-PUBLISH] Failed to publish highlight to relays:', error)
// If publishing fails completely, assume local-only mode
isLocalOnly = true
// Store metadata in cache (persists across EventStore serialization)
setHighlightMetadata(signedEvent.id, {
publishedRelays: [],
isLocalOnly: true,
isSyncing: false
})
// Also update the event with the error state (for backwards compatibility)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: [],
isLocalOnly: true,
isSyncing: false
}
// Store the event in EventStore AFTER updating with final properties
eventStore.add(signedEvent)
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
markEventAsOfflineCreated(signedEvent.id)
}
// Convert to Highlight with relay tracking info
const highlight = eventToHighlight(signedEvent)
// Manually set the properties since __highlightProps might not be working
const finalPublishedRelays = publishResponses.length > 0
? publishResponses
.filter(response => response.ok)
.map(response => response.from)
: []
highlight.publishedRelays = finalPublishedRelays
highlight.isLocalOnly = isLocalOnly
highlight.isSyncing = false
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
}
}