mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 06:34:24 +01:00
Merge pull request #29 from dergigi/flight-mode-shenanigans
Fix flight mode detection and persist highlight metadata
This commit is contained in:
@@ -88,13 +88,6 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
|
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
|
||||||
])
|
])
|
||||||
|
|
||||||
console.log('🔍 Modal fetch debug:', {
|
|
||||||
url: normalizedUrl,
|
|
||||||
hasContent: !!content,
|
|
||||||
hasOgData: !!ogData,
|
|
||||||
ogDataKeys: ogData ? Object.keys(ogData) : null
|
|
||||||
})
|
|
||||||
|
|
||||||
lastFetchedUrlRef.current = normalizedUrl
|
lastFetchedUrlRef.current = normalizedUrl
|
||||||
let extractedAnything = false
|
let extractedAnything = false
|
||||||
|
|
||||||
@@ -121,13 +114,6 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
if (!description && ogData) {
|
if (!description && ogData) {
|
||||||
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
||||||
|
|
||||||
console.log('🔍 Description extraction debug:', {
|
|
||||||
currentDescription: description,
|
|
||||||
hasOgData: !!ogData,
|
|
||||||
extractedDesc: extractedDesc,
|
|
||||||
willSetDescription: !!extractedDesc
|
|
||||||
})
|
|
||||||
|
|
||||||
if (extractedDesc) {
|
if (extractedDesc) {
|
||||||
setDescription(extractedDesc)
|
setDescription(extractedDesc)
|
||||||
extractedAnything = true
|
extractedAnything = true
|
||||||
|
|||||||
@@ -229,7 +229,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
currentArticle,
|
currentArticle,
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
readerContent,
|
readerContent,
|
||||||
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
|
onHighlightCreated: (highlight) => setHighlights(prev => {
|
||||||
|
// Deduplicate by checking if highlight with this ID already exists
|
||||||
|
const exists = prev.some(h => h.id === highlight.id)
|
||||||
|
if (exists) {
|
||||||
|
return prev // Don't add duplicate
|
||||||
|
}
|
||||||
|
return [highlight, ...prev]
|
||||||
|
}),
|
||||||
settings
|
settings
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { useEventModel } from 'applesauce-react/hooks'
|
|||||||
import { Models, IEventStore } from 'applesauce-core'
|
import { Models, IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
|
||||||
import { getActiveRelayUrls } from '../services/relayManager'
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
@@ -114,7 +114,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
const itemRef = useRef<HTMLDivElement>(null)
|
const itemRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
|
||||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
@@ -133,12 +132,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update offline indicator when highlight prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (highlight.isOfflineCreated && !isSyncing) {
|
|
||||||
setShowOfflineIndicator(true)
|
|
||||||
}
|
|
||||||
}, [highlight.isOfflineCreated, isSyncing])
|
|
||||||
|
|
||||||
// Listen to sync state changes
|
// Listen to sync state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,8 +140,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
setIsSyncing(syncingState)
|
setIsSyncing(syncingState)
|
||||||
// When sync completes successfully, update highlight to show all relays
|
// When sync completes successfully, update highlight to show all relays
|
||||||
if (!syncingState) {
|
if (!syncingState) {
|
||||||
setShowOfflineIndicator(false)
|
|
||||||
|
|
||||||
// Update the highlight with all relays after successful sync
|
// Update the highlight with all relays after successful sync
|
||||||
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
||||||
const updatedHighlight = {
|
const updatedHighlight = {
|
||||||
@@ -292,9 +283,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
onHighlightUpdate(updatedHighlight)
|
onHighlightUpdate(updatedHighlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setShowOfflineIndicator(false)
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to rebroadcast:', error)
|
console.error('❌ Failed to rebroadcast:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -313,8 +301,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show relay list, use plane icon for local-only
|
// Check if this highlight was only published to local relays
|
||||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
let isLocalOnly = highlight.isLocalOnly
|
||||||
|
const publishedRelays = highlight.publishedRelays || []
|
||||||
|
|
||||||
|
// Fallback 1: Check if this highlight was marked for offline sync (flight mode)
|
||||||
|
if (isLocalOnly === undefined) {
|
||||||
|
if (isEventOfflineCreated(highlight.id)) {
|
||||||
|
isLocalOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 2: If publishedRelays only contains local relays, it's local-only
|
||||||
|
if (isLocalOnly === undefined && publishedRelays.length > 0) {
|
||||||
|
const hasOnlyLocalRelays = publishedRelays.every(url => isLocalRelay(url))
|
||||||
|
const hasRemoteRelays = publishedRelays.some(url => !isLocalRelay(url))
|
||||||
|
if (hasOnlyLocalRelays && !hasRemoteRelays) {
|
||||||
|
isLocalOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// If isLocalOnly is true (from any fallback), show airplane icon
|
||||||
|
if (isLocalOnly === true) {
|
||||||
|
return {
|
||||||
|
icon: faPlane,
|
||||||
|
tooltip: publishedRelays.length > 0
|
||||||
|
? 'Local relays only - will sync when remote relays available'
|
||||||
|
: 'Created in flight mode - will sync when remote relays available',
|
||||||
|
spin: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show highlighter icon with relay info if available
|
// Show highlighter icon with relay info if available
|
||||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||||
@@ -322,7 +339,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
icon: faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,47 +85,18 @@ export function useArticleLoader({
|
|||||||
// when we know the article coordinate
|
// when we know the article coordinate
|
||||||
setHighlightsLoading(false) // Don't show loading yet
|
setHighlightsLoading(false) // Don't show loading yet
|
||||||
|
|
||||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
|
||||||
if (previewData) {
|
let foundInStore = false
|
||||||
setCurrentTitle(previewData.title)
|
if (eventStore) {
|
||||||
setReaderContent({
|
try {
|
||||||
title: previewData.title,
|
// Decode naddr to get the coordinate
|
||||||
markdown: '', // Will be loaded from store or relay
|
const decoded = nip19.decode(naddr)
|
||||||
image: previewData.image,
|
if (decoded.type === 'naddr') {
|
||||||
summary: previewData.summary,
|
const pointer = decoded.data as AddressPointer
|
||||||
published: previewData.published,
|
|
||||||
url: `nostr:${naddr}`
|
|
||||||
})
|
|
||||||
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
|
||||||
} else {
|
|
||||||
setReaderLoading(true)
|
|
||||||
setReaderContent(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Decode naddr to filter
|
|
||||||
const decoded = nip19.decode(naddr)
|
|
||||||
if (decoded.type !== 'naddr') {
|
|
||||||
throw new Error('Invalid naddr format')
|
|
||||||
}
|
|
||||||
const pointer = decoded.data as AddressPointer
|
|
||||||
const filter = {
|
|
||||||
kinds: [pointer.kind],
|
|
||||||
authors: [pointer.pubkey],
|
|
||||||
'#d': [pointer.identifier]
|
|
||||||
}
|
|
||||||
|
|
||||||
let firstEmitted = false
|
|
||||||
let latestEvent: NostrEvent | null = null
|
|
||||||
|
|
||||||
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
|
|
||||||
if (eventStore) {
|
|
||||||
try {
|
|
||||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||||
if (storedEvent) {
|
if (storedEvent) {
|
||||||
latestEvent = storedEvent as NostrEvent
|
foundInStore = true
|
||||||
firstEmitted = true
|
|
||||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||||
setCurrentTitle(title)
|
setCurrentTitle(title)
|
||||||
const image = Helpers.getArticleImage(storedEvent)
|
const image = Helpers.getArticleImage(storedEvent)
|
||||||
@@ -145,11 +116,50 @@ export function useArticleLoader({
|
|||||||
setCurrentArticleEventId(storedEvent.id)
|
setCurrentArticleEventId(storedEvent.id)
|
||||||
setCurrentArticle?.(storedEvent)
|
setCurrentArticle?.(storedEvent)
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
|
// If we found the content in EventStore, we can return early
|
||||||
|
// This prevents unnecessary relay queries when offline
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
// Ignore store errors, fall through to relay query
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore store errors, fall through to relay query
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||||
|
if (previewData) {
|
||||||
|
setCurrentTitle(previewData.title)
|
||||||
|
setReaderContent({
|
||||||
|
title: previewData.title,
|
||||||
|
markdown: '', // Will be loaded from store or relay
|
||||||
|
image: previewData.image,
|
||||||
|
summary: previewData.summary,
|
||||||
|
published: previewData.published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||||
|
} else if (!foundInStore) {
|
||||||
|
// Only show loading if we didn't find content in store and no preview data
|
||||||
|
setReaderLoading(true)
|
||||||
|
setReaderContent(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode naddr to filter
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') {
|
||||||
|
throw new Error('Invalid naddr format')
|
||||||
|
}
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const filter = {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstEmitted = false
|
||||||
|
let latestEvent: NostrEvent | null = null
|
||||||
|
|
||||||
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
||||||
const events = await queryEvents(relayPool, filter, {
|
const events = await queryEvents(relayPool, filter, {
|
||||||
@@ -305,19 +315,11 @@ export function useArticleLoader({
|
|||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
}
|
}
|
||||||
|
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
|
||||||
|
// This fixes the loading skeleton appearing when going offline (flight mode)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
naddr,
|
naddr,
|
||||||
relayPool,
|
previewData
|
||||||
eventStore,
|
|
||||||
previewData,
|
|
||||||
setSelectedUrl,
|
|
||||||
setReaderContent,
|
|
||||||
setReaderLoading,
|
|
||||||
setIsCollapsed,
|
|
||||||
setHighlights,
|
|
||||||
setHighlightsLoading,
|
|
||||||
setCurrentArticleCoordinate,
|
|
||||||
setCurrentArticleEventId,
|
|
||||||
setCurrentArticle
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,19 +165,12 @@ export function useExternalUrlLoader({
|
|||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
}
|
}
|
||||||
|
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
|
||||||
|
// This fixes the loading skeleton appearing when going offline (flight mode)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
url,
|
url,
|
||||||
relayPool,
|
cachedUrlHighlights
|
||||||
eventStore,
|
|
||||||
cachedUrlHighlights,
|
|
||||||
setReaderContent,
|
|
||||||
setReaderLoading,
|
|
||||||
setIsCollapsed,
|
|
||||||
setSelectedUrl,
|
|
||||||
setHighlights,
|
|
||||||
setCurrentArticleCoordinate,
|
|
||||||
setCurrentArticleEventId,
|
|
||||||
setHighlightsLoading
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// Keep UI highlights synced with cached store updates without reloading content
|
// Keep UI highlights synced with cached store updates without reloading content
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const useHighlightCreation = ({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||||
|
|
||||||
if (!activeAccount || !relayPool || !eventStore) {
|
if (!activeAccount || !relayPool || !eventStore) {
|
||||||
console.error('Missing requirements for highlight creation')
|
console.error('Missing requirements for highlight creation')
|
||||||
return
|
return
|
||||||
@@ -60,7 +61,6 @@ export const useHighlightCreation = ({
|
|||||||
? currentArticle.content
|
? currentArticle.content
|
||||||
: readerContent?.markdown || readerContent?.html
|
: readerContent?.markdown || readerContent?.html
|
||||||
|
|
||||||
|
|
||||||
const newHighlight = await createHighlight(
|
const newHighlight = await createHighlight(
|
||||||
text,
|
text,
|
||||||
source,
|
source,
|
||||||
@@ -73,7 +73,6 @@ export const useHighlightCreation = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Highlight created successfully
|
// Highlight created successfully
|
||||||
|
|
||||||
// Clear the browser's text selection immediately to allow DOM update
|
// Clear the browser's text selection immediately to allow DOM update
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection) {
|
if (selection) {
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ import { Helpers, IEventStore } from 'applesauce-core'
|
|||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
import { publishEvent } from './writeService'
|
import { setHighlightMetadata } from './highlightEventProcessor'
|
||||||
|
|
||||||
// Boris pubkey for zap splits
|
// Boris pubkey for zap splits
|
||||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||||
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||||
|
|
||||||
|
// Extended event type with highlight metadata
|
||||||
|
interface HighlightEvent extends NostrEvent {
|
||||||
|
__highlightProps?: {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
getHighlightContext,
|
getHighlightContext,
|
||||||
@@ -118,25 +127,111 @@ export async function createHighlight(
|
|||||||
// Sign the event
|
// Sign the event
|
||||||
const signedEvent = await factory.sign(highlightEvent)
|
const signedEvent = await factory.sign(highlightEvent)
|
||||||
|
|
||||||
// Use unified write service to store and publish
|
// Initialize custom properties on the event (will be updated after publishing)
|
||||||
await publishEvent(relayPool, eventStore, signedEvent)
|
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||||
|
publishedRelays: [],
|
||||||
|
isLocalOnly: false,
|
||||||
|
isSyncing: false
|
||||||
|
}
|
||||||
|
|
||||||
// Check current connection status for UI feedback
|
// Get only connected relays to avoid long timeouts
|
||||||
const connectedRelays = Array.from(relayPool.relays.values())
|
const connectedRelays = Array.from(relayPool.relays.values())
|
||||||
.filter(relay => relay.connected)
|
.filter(relay => relay.connected)
|
||||||
.map(relay => relay.url)
|
.map(relay => relay.url)
|
||||||
|
|
||||||
|
let publishResponses: { ok: boolean; message?: string; from: string }[] = []
|
||||||
|
let isLocalOnly = false
|
||||||
|
|
||||||
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
|
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)
|
const highlight = eventToHighlight(signedEvent)
|
||||||
highlight.publishedRelays = expectedSuccessRelays
|
|
||||||
|
// 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.isLocalOnly = isLocalOnly
|
||||||
highlight.isOfflineCreated = isLocalOnly
|
highlight.isSyncing = false
|
||||||
|
|
||||||
return highlight
|
return highlight
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { NostrEvent } from 'nostr-tools'
|
|||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
// Extended event type with highlight metadata
|
||||||
|
interface HighlightEvent extends NostrEvent {
|
||||||
|
__highlightProps?: {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
getHighlightContext,
|
getHighlightContext,
|
||||||
@@ -12,6 +21,66 @@ const {
|
|||||||
getHighlightAttributions
|
getHighlightAttributions
|
||||||
} = Helpers
|
} = Helpers
|
||||||
|
|
||||||
|
const METADATA_CACHE_KEY = 'highlightMetadataCache'
|
||||||
|
|
||||||
|
type HighlightMetadata = {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load highlight metadata from localStorage
|
||||||
|
*/
|
||||||
|
function loadHighlightMetadataFromStorage(): Map<string, HighlightMetadata> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(METADATA_CACHE_KEY)
|
||||||
|
if (!raw) return new Map()
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, HighlightMetadata>
|
||||||
|
return new Map(Object.entries(parsed))
|
||||||
|
} catch {
|
||||||
|
// Silently fail on parse errors or if storage is unavailable
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save highlight metadata to localStorage
|
||||||
|
*/
|
||||||
|
function saveHighlightMetadataToStorage(cache: Map<string, HighlightMetadata>): void {
|
||||||
|
try {
|
||||||
|
const record = Object.fromEntries(cache.entries())
|
||||||
|
localStorage.setItem(METADATA_CACHE_KEY, JSON.stringify(record))
|
||||||
|
} catch {
|
||||||
|
// Silently fail if storage is full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for highlight metadata that persists across EventStore serialization
|
||||||
|
* Key: event ID, Value: { publishedRelays, isLocalOnly, isSyncing }
|
||||||
|
*/
|
||||||
|
const highlightMetadataCache = loadHighlightMetadataFromStorage()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store highlight metadata for an event ID
|
||||||
|
*/
|
||||||
|
export function setHighlightMetadata(
|
||||||
|
eventId: string,
|
||||||
|
metadata: HighlightMetadata
|
||||||
|
): void {
|
||||||
|
highlightMetadataCache.set(eventId, metadata)
|
||||||
|
saveHighlightMetadataToStorage(highlightMetadataCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get highlight metadata for an event ID
|
||||||
|
*/
|
||||||
|
export function getHighlightMetadata(eventId: string): HighlightMetadata | undefined {
|
||||||
|
return highlightMetadataCache.get(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a NostrEvent to a Highlight object
|
* Convert a NostrEvent to a Highlight object
|
||||||
*/
|
*/
|
||||||
@@ -28,6 +97,12 @@ export function eventToHighlight(event: NostrEvent): Highlight {
|
|||||||
const eventReference = sourceEventPointer?.id ||
|
const eventReference = sourceEventPointer?.id ||
|
||||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||||
|
|
||||||
|
// Check cache first (persists across EventStore serialization)
|
||||||
|
const cachedMetadata = getHighlightMetadata(event.id)
|
||||||
|
|
||||||
|
// Fall back to __highlightProps if cache doesn't have it (for backwards compatibility)
|
||||||
|
const customProps = cachedMetadata || (event as HighlightEvent).__highlightProps || {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
@@ -38,7 +113,11 @@ export function eventToHighlight(event: NostrEvent): Highlight {
|
|||||||
urlReference: sourceUrl,
|
urlReference: sourceUrl,
|
||||||
author,
|
author,
|
||||||
context,
|
context,
|
||||||
comment
|
comment,
|
||||||
|
// Preserve custom properties if they exist
|
||||||
|
publishedRelays: customProps.publishedRelays,
|
||||||
|
isLocalOnly: customProps.isLocalOnly,
|
||||||
|
isSyncing: customProps.isSyncing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,42 @@ import { NostrEvent } from 'nostr-tools'
|
|||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { isLocalRelay } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
|
import { setHighlightMetadata, getHighlightMetadata } from './highlightEventProcessor'
|
||||||
|
|
||||||
|
const OFFLINE_EVENTS_KEY = 'offlineCreatedEvents'
|
||||||
|
|
||||||
let isSyncing = false
|
let isSyncing = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load offline events from localStorage
|
||||||
|
*/
|
||||||
|
function loadOfflineEventsFromStorage(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(OFFLINE_EVENTS_KEY)
|
||||||
|
if (!raw) return new Set()
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as string[]
|
||||||
|
return new Set(parsed)
|
||||||
|
} catch {
|
||||||
|
// Silently fail on parse errors or if storage is unavailable
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save offline events to localStorage
|
||||||
|
*/
|
||||||
|
function saveOfflineEventsToStorage(events: Set<string>): void {
|
||||||
|
try {
|
||||||
|
const array = Array.from(events)
|
||||||
|
localStorage.setItem(OFFLINE_EVENTS_KEY, JSON.stringify(array))
|
||||||
|
} catch {
|
||||||
|
// Silently fail if storage is full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track events created during offline period
|
// Track events created during offline period
|
||||||
const offlineCreatedEvents = new Set<string>()
|
const offlineCreatedEvents = loadOfflineEventsFromStorage()
|
||||||
|
|
||||||
// Track events currently being synced
|
// Track events currently being synced
|
||||||
const syncingEvents = new Set<string>()
|
const syncingEvents = new Set<string>()
|
||||||
@@ -20,6 +51,14 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
|
|||||||
*/
|
*/
|
||||||
export function markEventAsOfflineCreated(eventId: string): void {
|
export function markEventAsOfflineCreated(eventId: string): void {
|
||||||
offlineCreatedEvents.add(eventId)
|
offlineCreatedEvents.add(eventId)
|
||||||
|
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event was created during offline period (flight mode)
|
||||||
|
*/
|
||||||
|
export function isEventOfflineCreated(eventId: string): boolean {
|
||||||
|
return offlineCreatedEvents.has(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +126,7 @@ export async function syncLocalEventsToRemote(
|
|||||||
if (eventsToSync.length === 0) {
|
if (eventsToSync.length === 0) {
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
offlineCreatedEvents.clear()
|
offlineCreatedEvents.clear()
|
||||||
|
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +135,17 @@ export async function syncLocalEventsToRemote(
|
|||||||
new Map(eventsToSync.map(e => [e.id, e])).values()
|
new Map(eventsToSync.map(e => [e.id, e])).values()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mark all events as syncing
|
// Mark all events as syncing and update metadata
|
||||||
uniqueEvents.forEach(event => {
|
uniqueEvents.forEach(event => {
|
||||||
syncingEvents.add(event.id)
|
syncingEvents.add(event.id)
|
||||||
notifySyncStateChange(event.id, true)
|
notifySyncStateChange(event.id, true)
|
||||||
|
|
||||||
|
// Update metadata cache to reflect syncing state
|
||||||
|
const existingMetadata = getHighlightMetadata(event.id)
|
||||||
|
setHighlightMetadata(event.id, {
|
||||||
|
...existingMetadata,
|
||||||
|
isSyncing: true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Publish to remote relays
|
// Publish to remote relays
|
||||||
@@ -118,13 +165,32 @@ export async function syncLocalEventsToRemote(
|
|||||||
syncingEvents.delete(eventId)
|
syncingEvents.delete(eventId)
|
||||||
offlineCreatedEvents.delete(eventId)
|
offlineCreatedEvents.delete(eventId)
|
||||||
notifySyncStateChange(eventId, false)
|
notifySyncStateChange(eventId, false)
|
||||||
|
|
||||||
|
// Update metadata cache: sync complete, no longer local-only
|
||||||
|
const existingMetadata = getHighlightMetadata(eventId)
|
||||||
|
setHighlightMetadata(eventId, {
|
||||||
|
...existingMetadata,
|
||||||
|
isSyncing: false,
|
||||||
|
isLocalOnly: false
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Save updated offline events set to localStorage
|
||||||
|
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||||
|
|
||||||
// Clear syncing state for failed events
|
// Clear syncing state for failed events
|
||||||
uniqueEvents.forEach(event => {
|
uniqueEvents.forEach(event => {
|
||||||
if (!successfulIds.includes(event.id)) {
|
if (!successfulIds.includes(event.id)) {
|
||||||
syncingEvents.delete(event.id)
|
syncingEvents.delete(event.id)
|
||||||
notifySyncStateChange(event.id, false)
|
notifySyncStateChange(event.id, false)
|
||||||
|
|
||||||
|
// Update metadata cache: sync failed, still local-only
|
||||||
|
const existingMetadata = getHighlightMetadata(event.id)
|
||||||
|
setHighlightMetadata(event.id, {
|
||||||
|
...existingMetadata,
|
||||||
|
isSyncing: false
|
||||||
|
// Keep isLocalOnly as true (sync failed)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function applyRelaySetToPool(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Suppress errors when closing relays that haven't fully connected yet
|
// Suppress errors when closing relays that haven't fully connected yet
|
||||||
// This can happen when switching relay sets before connections establish
|
// This can happen when switching relay sets before connections establish
|
||||||
console.debug('[relay-manager] Ignoring error when closing relay:', url, error)
|
// Silently ignore
|
||||||
}
|
}
|
||||||
relayPool.relays.delete(url)
|
relayPool.relays.delete(url)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ export interface Highlight {
|
|||||||
comment?: string // optional comment about the highlight
|
comment?: string // optional comment about the highlight
|
||||||
// Level classification (computed based on user's context)
|
// Level classification (computed based on user's context)
|
||||||
level?: HighlightLevel
|
level?: HighlightLevel
|
||||||
// Relay tracking for offline/local-only highlights
|
// Relay tracking for local-only highlights
|
||||||
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
|
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
|
||||||
seenOnRelays?: string[] // URLs of relays where this event was fetched from
|
seenOnRelays?: string[] // URLs of relays where this event was fetched from
|
||||||
isLocalOnly?: boolean // true if only published to local relays
|
isLocalOnly?: boolean // true if only published to local relays
|
||||||
isOfflineCreated?: boolean // true if created while in flight mode (offline)
|
|
||||||
isSyncing?: boolean // true if currently being synced to remote relays
|
isSyncing?: boolean // true if currently being synced to remote relays
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user