feat: add relay rebroadcast settings for caching and propagation

- Add two new settings:
  - Use local relay(s) as cache (default: enabled)
  - Rebroadcast events to all relays (default: disabled)
- Create rebroadcastService to handle rebroadcasting events
- Hook into article, bookmark, and highlight fetching services
- Automatically rebroadcast fetched events based on settings:
  - Articles when opened
  - Bookmarks when fetched
  - Highlights when fetched
- Add RelayRebroadcastSettings component with plane/globe icons
- Benefits:
  - Local caching for offline access
  - Content propagation across nostr network
  - User control over bandwidth usage
This commit is contained in:
Gigi
2025-10-09 13:01:38 +01:00
parent 831cb18b66
commit b055294afc
10 changed files with 203 additions and 17 deletions

View File

@@ -88,7 +88,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager, accountManager,
naddr, naddr,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId currentArticleEventId,
settings
}) })
const { const {
@@ -134,7 +135,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading, setHighlightsLoading,
setCurrentArticleCoordinate, setCurrentArticleCoordinate,
setCurrentArticleEventId, setCurrentArticleEventId,
setCurrentArticle setCurrentArticle,
settings
}) })
// Load external URL if /r/* route is used // Load external URL if /r/* route is used

View File

@@ -9,6 +9,7 @@ import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings' import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings' import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings' import RelaySettings from './Settings/RelaySettings'
import RelayRebroadcastSettings from './Settings/RelayRebroadcastSettings'
import { useRelayStatus } from '../hooks/useRelayStatus' import { useRelayStatus } from '../hooks/useRelayStatus'
const DEFAULT_SETTINGS: UserSettings = { const DEFAULT_SETTINGS: UserSettings = {
@@ -30,6 +31,8 @@ const DEFAULT_SETTINGS: UserSettings = {
zapSplitHighlighterWeight: 50, zapSplitHighlighterWeight: 50,
zapSplitBorisWeight: 2.1, zapSplitBorisWeight: 2.1,
zapSplitAuthorWeight: 50, zapSplitAuthorWeight: 50,
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
} }
interface SettingsProps { interface SettingsProps {
@@ -159,6 +162,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} /> <LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} /> <StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} /> <ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<RelayRebroadcastSettings settings={localSettings} onUpdate={handleUpdate} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} /> <RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,71 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlane, faGlobe, faInfoCircle } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
interface RelayRebroadcastSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const RelayRebroadcastSettings: React.FC<RelayRebroadcastSettingsProps> = ({
settings,
onUpdate
}) => {
return (
<div className="settings-section">
<h3>Relay Rebroadcast</h3>
<div className="settings-group">
<label className="settings-checkbox-label">
<input
type="checkbox"
checked={settings.useLocalRelayAsCache ?? true}
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
/>
<div className="settings-label-content">
<div className="settings-label-title">
<FontAwesomeIcon icon={faPlane} style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
Use local relay(s) as cache
</div>
<div className="settings-label-description">
Rebroadcast articles, bookmarks, and highlights to your local relays when fetched.
Helps keep your data available offline.
</div>
</div>
</label>
</div>
<div className="settings-group">
<label className="settings-checkbox-label">
<input
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
/>
<div className="settings-label-content">
<div className="settings-label-title">
<FontAwesomeIcon icon={faGlobe} style={{ marginRight: '0.5rem', color: '#646cff' }} />
Rebroadcast events to all relays
</div>
<div className="settings-label-description">
Rebroadcast articles, bookmarks, and highlights to all your relays.
Helps propagate content across the nostr network.
</div>
</div>
</label>
</div>
<div className="settings-info-box">
<FontAwesomeIcon icon={faInfoCircle} style={{ marginRight: '0.5rem' }} />
<div>
<strong>Why rebroadcast?</strong> Rebroadcasting helps preserve content and makes it available
on more relays. Local caching ensures you can access your bookmarks and highlights even when offline.
</div>
</div>
</div>
)
}
export default RelayRebroadcastSettings

View File

@@ -5,6 +5,7 @@ import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService' import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
interface UseArticleLoaderProps { interface UseArticleLoaderProps {
naddr: string | undefined naddr: string | undefined
@@ -18,6 +19,7 @@ interface UseArticleLoaderProps {
setCurrentArticleCoordinate: (coord: string | undefined) => void setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void setCurrentArticleEventId: (id: string | undefined) => void
setCurrentArticle?: (article: NostrEvent) => void setCurrentArticle?: (article: NostrEvent) => void
settings?: UserSettings
} }
export function useArticleLoader({ export function useArticleLoader({
@@ -31,7 +33,8 @@ export function useArticleLoader({
setHighlightsLoading, setHighlightsLoading,
setCurrentArticleCoordinate, setCurrentArticleCoordinate,
setCurrentArticleEventId, setCurrentArticleEventId,
setCurrentArticle setCurrentArticle,
settings
}: UseArticleLoaderProps) { }: UseArticleLoaderProps) {
useEffect(() => { useEffect(() => {
if (!relayPool || !naddr) return if (!relayPool || !naddr) return
@@ -44,7 +47,7 @@ export function useArticleLoader({
// Keep highlights panel collapsed by default - only open on user interaction // Keep highlights panel collapsed by default - only open on user interaction
try { try {
const article = await fetchArticleByNaddr(relayPool, naddr) const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
setReaderContent({ setReaderContent({
title: article.title, title: article.title,
markdown: article.markdown, markdown: article.markdown,
@@ -86,7 +89,8 @@ export function useArticleLoader({
const highlightsList = Array.from(highlightsMap.values()) const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
} }
} },
settings
) )
console.log(`📌 Found ${highlightsMap.size} highlights`) console.log(`📌 Found ${highlightsMap.size} highlights`)
} catch (err) { } catch (err) {
@@ -106,5 +110,5 @@ export function useArticleLoader({
} }
loadArticle() loadArticle()
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle]) }, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
} }

View File

@@ -5,6 +5,7 @@ import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService' import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService' import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService' import { fetchContacts } from '../services/contactService'
import { UserSettings } from '../services/settingsService'
interface UseBookmarksDataParams { interface UseBookmarksDataParams {
relayPool: RelayPool | null relayPool: RelayPool | null
@@ -15,6 +16,7 @@ interface UseBookmarksDataParams {
naddr?: string naddr?: string
currentArticleCoordinate?: string currentArticleCoordinate?: string
currentArticleEventId?: string currentArticleEventId?: string
settings?: UserSettings
} }
export const useBookmarksData = ({ export const useBookmarksData = ({
@@ -23,7 +25,8 @@ export const useBookmarksData = ({
accountManager, accountManager,
naddr, naddr,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId currentArticleEventId,
settings
}: UseBookmarksDataParams) => { }: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true) const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -43,11 +46,11 @@ export const useBookmarksData = ({
setBookmarksLoading(true) setBookmarksLoading(true)
try { try {
const fullAccount = accountManager.getActive() const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks) await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
} finally { } finally {
setBookmarksLoading(false) setBookmarksLoading(false)
} }
}, [relayPool, activeAccount, accountManager]) }, [relayPool, activeAccount, accountManager, settings])
const handleFetchHighlights = useCallback(async () => { const handleFetchHighlights = useCallback(async () => {
if (!relayPool) return if (!relayPool) return
@@ -67,11 +70,12 @@ export const useBookmarksData = ({
const highlightsList = Array.from(highlightsMap.values()) const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
} }
} },
settings
) )
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`) console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
} else if (activeAccount) { } else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey) const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
setHighlights(fetchedHighlights) setHighlights(fetchedHighlights)
} }
} catch (err) { } catch (err) {
@@ -79,7 +83,7 @@ export const useBookmarksData = ({
} finally { } finally {
setHighlightsLoading(false) setHighlightsLoading(false)
} }
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId]) }, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
const handleRefreshAll = useCallback(async () => { const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return if (!relayPool || !activeAccount || isRefreshing) return

View File

@@ -5,6 +5,8 @@ import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -71,11 +73,13 @@ function saveToCache(naddr: string, content: ArticleContent): void {
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param naddr - The article's naddr * @param naddr - The article's naddr
* @param bypassCache - If true, skip cache and fetch fresh from relays * @param bypassCache - If true, skip cache and fetch fresh from relays
* @param settings - User settings for rebroadcast options
*/ */
export async function fetchArticleByNaddr( export async function fetchArticleByNaddr(
relayPool: RelayPool, relayPool: RelayPool,
naddr: string, naddr: string,
bypassCache = false bypassCache = false,
settings?: UserSettings
): Promise<ArticleContent> { ): Promise<ArticleContent> {
try { try {
// Check cache first unless bypassed // Check cache first unless bypassed
@@ -120,6 +124,9 @@ export async function fetchArticleByNaddr(
events.sort((a, b) => b.created_at - a.created_at) events.sort((a, b) => b.created_at - a.created_at)
const article = events[0] const article = events[0]
// Rebroadcast article to local/all relays based on settings
await rebroadcastEvents([article], relayPool, settings)
const title = getArticleTitle(article) || 'Untitled Article' const title = getArticleTitle(article) || 'Untitled Article'
const image = getArticleImage(article) const image = getArticleImage(article)
const published = getArticlePublished(article) const published = getArticlePublished(article)

View File

@@ -14,13 +14,16 @@ import {
} from './bookmarkHelpers' } from './bookmarkHelpers'
import { Bookmark } from '../types/bookmarks' import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts' import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
export const fetchBookmarks = async ( export const fetchBookmarks = async (
relayPool: RelayPool, relayPool: RelayPool,
activeAccount: unknown, // Full account object with extension capabilities activeAccount: unknown, // Full account object with extension capabilities
setBookmarks: (bookmarks: Bookmark[]) => void setBookmarks: (bookmarks: Bookmark[]) => void,
settings?: UserSettings
) => { ) => {
try { try {
@@ -38,6 +41,9 @@ export const fetchBookmarks = async (
) )
console.log('📊 Raw events fetched:', rawEvents.length, 'events') console.log('📊 Raw events fetched:', rawEvents.length, 'events')
// Rebroadcast bookmark events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Check for events with potentially encrypted content // Check for events with potentially encrypted content
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0) const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)
if (eventsWithContent.length > 0) { if (eventsWithContent.length > 0) {

View File

@@ -4,18 +4,23 @@ import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
/** /**
* Fetches highlights for a specific article by its address coordinate and/or event ID * Fetches highlights for a specific article by its address coordinate and/or event ID
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article") * @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
* @param eventId - Optional event ID to also query by 'e' tag * @param eventId - Optional event ID to also query by 'e' tag
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/ */
export const fetchHighlightsForArticle = async ( export const fetchHighlightsForArticle = async (
relayPool: RelayPool, relayPool: RelayPool,
articleCoordinate: string, articleCoordinate: string,
eventId?: string, eventId?: string,
onHighlight?: (highlight: Highlight) => void onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate) console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
@@ -75,6 +80,9 @@ export const fetchHighlightsForArticle = async (
const rawEvents = [...aTagEvents, ...eTagEvents] const rawEvents = [...aTagEvents, ...eTagEvents]
console.log('📊 Total raw highlight events fetched:', rawEvents.length) console.log('📊 Total raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
if (rawEvents.length > 0) { if (rawEvents.length > 0) {
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2)) console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
} else { } else {
@@ -99,10 +107,12 @@ export const fetchHighlightsForArticle = async (
* Fetches highlights for a specific URL * Fetches highlights for a specific URL
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param url - The external URL to find highlights for * @param url - The external URL to find highlights for
* @param settings - User settings for rebroadcast options
*/ */
export const fetchHighlightsForUrl = async ( export const fetchHighlightsForUrl = async (
relayPool: RelayPool, relayPool: RelayPool,
url: string url: string,
settings?: UserSettings
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
console.log('🔍 Fetching highlights (kind 9802) for URL:', url) console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
@@ -124,6 +134,9 @@ export const fetchHighlightsForUrl = async (
console.log('📊 Highlights for URL:', rawEvents.length) console.log('📊 Highlights for URL:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents) const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights) return sortHighlights(highlights)
@@ -138,11 +151,13 @@ export const fetchHighlightsForUrl = async (
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param pubkey - The user's public key * @param pubkey - The user's public key
* @param onHighlight - Optional callback to receive highlights as they arrive * @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/ */
export const fetchHighlights = async ( export const fetchHighlights = async (
relayPool: RelayPool, relayPool: RelayPool,
pubkey: string, pubkey: string,
onHighlight?: (highlight: Highlight) => void onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
@@ -172,6 +187,9 @@ export const fetchHighlights = async (
console.log('📊 Raw highlight events fetched:', rawEvents.length) console.log('📊 Raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Deduplicate and process events // Deduplicate and process events
const uniqueEvents = dedupeHighlights(rawEvents) const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length) console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)

View File

@@ -0,0 +1,67 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from './settingsService'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
/**
* Rebroadcasts events to relays based on user settings
* @param events Events to rebroadcast
* @param relayPool The relay pool to use for publishing
* @param settings User settings to determine which relays to broadcast to
*/
export async function rebroadcastEvents(
events: NostrEvent[],
relayPool: RelayPool,
settings?: UserSettings
): Promise<void> {
if (!events || events.length === 0) {
return
}
// Check if any rebroadcast is enabled
const useLocalCache = settings?.useLocalRelayAsCache ?? true
const broadcastToAll = settings?.rebroadcastToAllRelays ?? false
if (!useLocalCache && !broadcastToAll) {
return // No rebroadcast enabled
}
// Determine target relays based on settings
let targetRelays: string[] = []
if (broadcastToAll) {
// Broadcast to all relays
targetRelays = RELAYS
} else if (useLocalCache) {
// Only broadcast to local relays
targetRelays = RELAYS.filter(isLocalRelay)
}
if (targetRelays.length === 0) {
console.log('📡 No target relays for rebroadcast')
return
}
// Rebroadcast each event
const rebroadcastPromises = events.map(async (event) => {
try {
await relayPool.publish(targetRelays, event)
console.log('📡 Rebroadcast event', event.id?.slice(0, 8), 'to', targetRelays.length, 'relay(s)')
} catch (error) {
console.warn('⚠️ Failed to rebroadcast event', event.id?.slice(0, 8), error)
}
})
// Execute all rebroadcasts (don't block on completion)
Promise.all(rebroadcastPromises).catch((err) => {
console.warn('⚠️ Some rebroadcasts failed:', err)
})
console.log(`📡 Rebroadcasting ${events.length} event(s) to ${targetRelays.length} relay(s)`, {
broadcastToAll,
useLocalCache,
targetRelays
})
}

View File

@@ -39,6 +39,9 @@ export interface UserSettings {
zapSplitHighlighterWeight?: number // default 50 zapSplitHighlighterWeight?: number // default 50
zapSplitBorisWeight?: number // default 2.1 zapSplitBorisWeight?: number // default 2.1
zapSplitAuthorWeight?: number // default 50 zapSplitAuthorWeight?: number // default 50
// Relay rebroadcast settings
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
} }
export async function loadSettings( export async function loadSettings(