feat: implement optimistic updates for highlight creation

- 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
This commit is contained in:
Gigi
2025-10-06 19:51:14 +01:00
parent 06c3c1ff20
commit 872d38c7f3
3 changed files with 54 additions and 32 deletions

2
dist/index.html vendored
View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boris - Nostr Bookmarks</title> <title>Boris - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-BkbQg5P5.js"></script> <script type="module" crossorigin src="/assets/index-CsB0QtFa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bqz-n1DY.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bqz-n1DY.css">
</head> </head>
<body> <body>

View File

@@ -7,7 +7,7 @@ import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { BookmarkList } from './BookmarkList' import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService' import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle, fetchHighlightsForUrl } from '../services/highlightService' import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService' import { fetchContacts } from '../services/contactService'
import ContentPanel from './ContentPanel' import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel' import { HighlightsPanel } from './HighlightsPanel'
@@ -20,7 +20,7 @@ import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader' import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { HighlightVisibility } from './HighlightsPanel' import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButton, HighlightButtonRef } from './HighlightButton' import { HighlightButton, HighlightButtonRef } from './HighlightButton'
import { createHighlight } from '../services/highlightCreationService' import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
import { useRef, useCallback } from 'react' import { useRef, useCallback } from 'react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
export type ViewMode = 'compact' | 'cards' | 'large' export type ViewMode = 'compact' | 'cards' | 'large'
@@ -223,30 +223,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} }
} }
const handleHighlightCreated = async () => {
// Refresh highlights after creating a new one
if (!relayPool) return
try {
// Refresh based on what we're currently viewing
if (currentArticleCoordinate) {
// Viewing a nostr article - fetch by article coordinate
const newHighlights = await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
currentArticleEventId
)
setHighlights(newHighlights)
} else if (selectedUrl && !selectedUrl.startsWith('nostr:')) {
// Viewing an external URL - fetch by URL
const newHighlights = await fetchHighlightsForUrl(relayPool, selectedUrl)
setHighlights(newHighlights)
}
} catch (err) {
console.error('Failed to refresh highlights:', err)
}
}
const handleTextSelection = useCallback((text: string) => { const handleTextSelection = useCallback((text: string) => {
highlightButtonRef.current?.updateSelection(text) highlightButtonRef.current?.updateSelection(text)
}, []) }, [])
@@ -276,7 +252,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
? currentArticle.content ? currentArticle.content
: readerContent?.markdown || readerContent?.html : readerContent?.markdown || readerContent?.html
await createHighlight( // Create and publish the highlight
const signedEvent = await createHighlight(
text, text,
source, source,
activeAccount, activeAccount,
@@ -287,12 +264,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
console.log('✅ Highlight created successfully!') console.log('✅ Highlight created successfully!')
highlightButtonRef.current?.clearSelection() highlightButtonRef.current?.clearSelection()
// Trigger refresh of highlights // Immediately add the highlight to the UI (optimistic update)
handleHighlightCreated() const newHighlight = eventToHighlight(signedEvent)
setHighlights(prev => [newHighlight, ...prev])
} catch (error) { } catch (error) {
console.error('Failed to create highlight:', error) console.error('Failed to create highlight:', error)
} }
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent, handleHighlightCreated]) }, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent])
return ( return (
<> <>

View File

@@ -5,10 +5,21 @@ import { IAccount } from 'applesauce-accounts'
import { AddressPointer } from 'nostr-tools/nip19' import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays' 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) * Creates and publishes a highlight event (NIP-84)
* Supports both nostr-native articles and external URLs * Supports both nostr-native articles and external URLs
* Returns the signed event for immediate UI updates
*/ */
export async function createHighlight( export async function createHighlight(
selectedText: string, selectedText: string,
@@ -17,7 +28,7 @@ export async function createHighlight(
relayPool: RelayPool, relayPool: RelayPool,
contentForContext?: string, contentForContext?: string,
comment?: string comment?: string
): Promise<void> { ): Promise<NostrEvent> {
if (!selectedText || !source) { if (!selectedText || !source) {
throw new Error('Missing required data to create highlight') throw new Error('Missing required data to create highlight')
} }
@@ -65,6 +76,9 @@ export async function createHighlight(
await relayPool.publish(RELAYS, signedEvent) await relayPool.publish(RELAYS, signedEvent)
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent) console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent)
// Return the signed event for immediate UI updates
return signedEvent
} }
/** /**
@@ -159,3 +173,33 @@ function extractContext(selectedText: string, articleContent: string): string |
return contextParts.length > 1 ? contextParts.join(' ') : undefined 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
}
}