mirror of
https://github.com/dergigi/boris.git
synced 2026-01-10 02:14:56 +01:00
- Update createHighlight to return the signed NostrEvent - Add eventToHighlight helper to convert events to Highlight objects - Immediately add new highlights to UI without fetching from relays - Remove handleHighlightCreated refresh logic (no longer needed) - Improves UX with instant feedback when creating highlights
206 lines
6.8 KiB
TypeScript
206 lines
6.8 KiB
TypeScript
import { EventFactory } from 'applesauce-factory'
|
|
import { HighlightBlueprint } from 'applesauce-factory/blueprints'
|
|
import { RelayPool } from 'applesauce-relay'
|
|
import { IAccount } from 'applesauce-accounts'
|
|
import { AddressPointer } from 'nostr-tools/nip19'
|
|
import { NostrEvent } from 'nostr-tools'
|
|
import { RELAYS } from '../config/relays'
|
|
import { Highlight } from '../types/highlights'
|
|
import {
|
|
getHighlightText,
|
|
getHighlightContext,
|
|
getHighlightComment,
|
|
getHighlightSourceEventPointer,
|
|
getHighlightSourceAddressPointer,
|
|
getHighlightSourceUrl,
|
|
getHighlightAttributions
|
|
} from 'applesauce-core/helpers'
|
|
|
|
/**
|
|
* Creates and publishes a highlight event (NIP-84)
|
|
* Supports both nostr-native articles and external URLs
|
|
* Returns the signed event for immediate UI updates
|
|
*/
|
|
export async function createHighlight(
|
|
selectedText: string,
|
|
source: NostrEvent | string,
|
|
account: IAccount,
|
|
relayPool: RelayPool,
|
|
contentForContext?: string,
|
|
comment?: string
|
|
): Promise<NostrEvent> {
|
|
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. readwithboris.com']
|
|
} else {
|
|
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
|
|
}
|
|
|
|
// Sign the event
|
|
const signedEvent = await factory.sign(highlightEvent)
|
|
|
|
// Publish to relays (including local relay)
|
|
await relayPool.publish(RELAYS, signedEvent)
|
|
|
|
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent)
|
|
|
|
// Return the signed event for immediate UI updates
|
|
return signedEvent
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
}
|
|
|