Merge pull request #29 from dergigi/flight-mode-shenanigans

Fix flight mode detection and persist highlight metadata
This commit is contained in:
Gigi
2025-10-31 00:26:08 +01:00
committed by GitHub
11 changed files with 359 additions and 116 deletions

View File

@@ -88,13 +88,6 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
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
let extractedAnything = false
@@ -121,13 +114,6 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
if (!description && ogData) {
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) {
setDescription(extractedDesc)
extractedAnything = true

View File

@@ -229,7 +229,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({
currentArticle,
selectedUrl,
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
})

View File

@@ -7,8 +7,8 @@ import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { areAllRelaysLocal } from '../utils/helpers'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
@@ -114,7 +114,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = 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
}
// Update offline indicator when highlight prop changes
useEffect(() => {
if (highlight.isOfflineCreated && !isSyncing) {
setShowOfflineIndicator(true)
}
}, [highlight.isOfflineCreated, isSyncing])
// Listen to sync state changes
useEffect(() => {
@@ -147,8 +140,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setIsSyncing(syncingState)
// When sync completes successfully, update highlight to show all relays
if (!syncingState) {
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
const updatedHighlight = {
@@ -292,9 +283,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onHighlightUpdate(updatedHighlight)
}
// Update local state
setShowOfflineIndicator(false)
} catch (error) {
console.error('❌ Failed to rebroadcast:', error)
} finally {
@@ -313,8 +301,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Check if this highlight was only published to local relays
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
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
@@ -322,7 +339,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faHighlighter,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}

View File

@@ -85,47 +85,18 @@ export function useArticleLoader({
// when we know the article coordinate
setHighlightsLoading(false) // Don't show loading yet
// 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 {
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 {
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
let foundInStore = false
if (eventStore) {
try {
// Decode naddr to get the coordinate
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const pointer = decoded.data as AddressPointer
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
latestEvent = storedEvent as NostrEvent
firstEmitted = true
foundInStore = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(storedEvent)
@@ -145,11 +116,50 @@ export function useArticleLoader({
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
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)
const events = await queryEvents(relayPool, filter, {
@@ -305,19 +315,11 @@ export function useArticleLoader({
return () => {
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,
relayPool,
eventStore,
previewData,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
previewData
])
}

View File

@@ -165,19 +165,12 @@ export function useExternalUrlLoader({
return () => {
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,
relayPool,
eventStore,
cachedUrlHighlights,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setSelectedUrl,
setHighlights,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setHighlightsLoading
cachedUrlHighlights
])
// Keep UI highlights synced with cached store updates without reloading content

View File

@@ -44,6 +44,7 @@ export const useHighlightCreation = ({
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool || !eventStore) {
console.error('Missing requirements for highlight creation')
return
@@ -60,7 +61,6 @@ export const useHighlightCreation = ({
? currentArticle.content
: readerContent?.markdown || readerContent?.html
const newHighlight = await createHighlight(
text,
source,
@@ -73,7 +73,6 @@ export const useHighlightCreation = ({
)
// Highlight created successfully
// Clear the browser's text selection immediately to allow DOM update
const selection = window.getSelection()
if (selection) {

View File

@@ -7,13 +7,22 @@ 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'
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,
@@ -118,25 +127,111 @@ export async function createHighlight(
// Sign the event
const signedEvent = await factory.sign(highlightEvent)
// Use unified write service to store and publish
await publishEvent(relayPool, eventStore, signedEvent)
// Initialize custom properties on the event (will be updated after publishing)
;(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())
.filter(relay => relay.connected)
.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)
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.isOfflineCreated = isLocalOnly
highlight.isSyncing = false
return highlight
}

View File

@@ -2,6 +2,15 @@ import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Highlight } from '../types/highlights'
// Extended event type with highlight metadata
interface HighlightEvent extends NostrEvent {
__highlightProps?: {
publishedRelays?: string[]
isLocalOnly?: boolean
isSyncing?: boolean
}
}
const {
getHighlightText,
getHighlightContext,
@@ -12,6 +21,66 @@ const {
getHighlightAttributions
} = 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
*/
@@ -28,6 +97,12 @@ export function eventToHighlight(event: NostrEvent): Highlight {
const eventReference = sourceEventPointer?.id ||
(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 {
id: event.id,
pubkey: event.pubkey,
@@ -38,7 +113,11 @@ export function eventToHighlight(event: NostrEvent): Highlight {
urlReference: sourceUrl,
author,
context,
comment
comment,
// Preserve custom properties if they exist
publishedRelays: customProps.publishedRelays,
isLocalOnly: customProps.isLocalOnly,
isSyncing: customProps.isSyncing
}
}

View File

@@ -3,11 +3,42 @@ import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
import { setHighlightMetadata, getHighlightMetadata } from './highlightEventProcessor'
const OFFLINE_EVENTS_KEY = 'offlineCreatedEvents'
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
const offlineCreatedEvents = new Set<string>()
const offlineCreatedEvents = loadOfflineEventsFromStorage()
// Track events currently being synced
const syncingEvents = new Set<string>()
@@ -20,6 +51,14 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
*/
export function markEventAsOfflineCreated(eventId: string): void {
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) {
isSyncing = false
offlineCreatedEvents.clear()
saveOfflineEventsToStorage(offlineCreatedEvents)
return
}
@@ -95,10 +135,17 @@ export async function syncLocalEventsToRemote(
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 => {
syncingEvents.add(event.id)
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
@@ -118,13 +165,32 @@ export async function syncLocalEventsToRemote(
syncingEvents.delete(eventId)
offlineCreatedEvents.delete(eventId)
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
uniqueEvents.forEach(event => {
if (!successfulIds.includes(event.id)) {
syncingEvents.delete(event.id)
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) {

View File

@@ -88,7 +88,7 @@ export function applyRelaySetToPool(
} catch (error) {
// Suppress errors when closing relays that haven't fully connected yet
// 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)
}

View File

@@ -15,11 +15,10 @@ export interface Highlight {
comment?: string // optional comment about the highlight
// Level classification (computed based on user's context)
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)
seenOnRelays?: string[] // URLs of relays where this event was fetched from
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
}