feat: integrate user relay lists (NIP-65) and blocked relays (NIP-51)

- Add relayListService to load kind:10002 (user relay list) and kind:10006 (blocked relays)
- Add relayManager to compute active relay set and dynamically manage pool membership
- Update App.tsx to fetch and apply user relays on login, reset on logout
- Replace all hardcoded RELAYS usages with dynamic getActiveRelayUrls() across services and components
- Always preserve localhost relays (ws://localhost:10547, ws://localhost:4869) regardless of user blocks
- Merge bunker relays, user relays, and hardcoded relays while excluding blocked relays
- Update keep-alive subscription and address loaders to use dynamic relay set
- Modified files: App.tsx, relayListService.ts (new), relayManager.ts (new), readsService.ts, readingProgressController.ts, archiveController.ts, libraryService.ts, reactionService.ts, writeService.ts, HighlightItem.tsx, ContentPanel.tsx, BookmarkList.tsx, Profile.tsx
This commit is contained in:
Gigi
2025-10-20 18:40:23 +02:00
parent 88db14c352
commit fc025b9579
13 changed files with 308 additions and 41 deletions

View File

@@ -19,6 +19,8 @@ import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
import { DebugBus } from './utils/debugBus'
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS } from './services/relayManager'
import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
@@ -602,6 +604,86 @@ function App() {
}
})
// Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe(async (account) => {
if (account) {
// User logged in - load their relay list and apply it
try {
const pubkey = account.pubkey
// Load user's relay list (10002) and blocked relays (10006) in parallel
const [userRelayList, blockedRelays] = await Promise.all([
loadUserRelayList(pool, pubkey),
loadBlockedRelays(pool, pubkey)
])
// Get bunker relays if this is a nostr-connect account
let bunkerRelays: string[] = []
if (account.type === 'nostr-connect') {
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
const signerData = nostrConnectAccount.toJSON().signer
bunkerRelays = signerData.relays || []
}
// Compute final relay set
const finalRelays = computeRelaySet({
hardcoded: RELAYS,
bunker: bunkerRelays,
userList: userRelayList,
blocked: blockedRelays,
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
// Apply to pool
applyRelaySetToPool(pool, finalRelays)
// Update keep-alive subscription with new relay set
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const activeRelays = getActiveRelayUrls(pool)
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: (err) => console.warn('Keep-alive subscription error:', err)
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
// Update address loader with new relays
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: activeRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
} catch (error) {
console.error('Failed to load user relays:', error)
}
} else {
// User logged out - reset to hardcoded relays
applyRelaySetToPool(pool, RELAYS)
// Update keep-alive subscription
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: (err) => console.warn('Keep-alive subscription error:', err)
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
// Reset address loader
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: RELAYS
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}
})
// Keep all relay connections alive indefinitely by creating a persistent subscription
// This prevents disconnection when no other subscriptions are active
// Create a minimal subscription that never completes to keep connections alive
@@ -630,6 +712,7 @@ function App() {
accountsSub.unsubscribe()
activeSub.unsubscribe()
bunkerReconnectSub.unsubscribe()
userRelaysSub.unsubscribe()
// Clean up keep-alive subscription if it exists
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {

View File

@@ -17,8 +17,8 @@ import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWith
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import { getActiveRelayUrls } from '../services/relayManager'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions'
@@ -125,7 +125,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
}
// Pull-to-refresh for bookmarks

View File

@@ -10,8 +10,8 @@ import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt,
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
@@ -357,7 +357,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (!currentArticle) return null
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)

View File

@@ -8,8 +8,8 @@ import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
@@ -150,10 +150,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly) {
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
const updatedHighlight = {
...highlight,
publishedRelays: RELAYS,
publishedRelays: getActiveRelayUrls(relayPool),
isLocalOnly: false,
isOfflineCreated: false
}
@@ -164,7 +164,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
})
return unsubscribe
}, [highlight, onHighlightUpdate])
}, [highlight, onHighlightUpdate, relayPool])
useEffect(() => {
if (isSelected && itemRef.current) {
@@ -224,7 +224,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
@@ -260,7 +261,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
const targetRelays = getActiveRelayUrls(relayPool)
await relayPool.publish(targetRelays, event)
@@ -328,7 +329,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Fallback: show all relays we queried (where this was likely fetched from)
const relayNames = RELAYS.map(url =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayNames = activeRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {

View File

@@ -8,8 +8,8 @@ import { useNavigate } from 'react-router-dom'
import { HighlightItem } from './HighlightItem'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { fetchHighlights } from '../services/highlightService'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { getActiveRelayUrls } from '../services/relayManager'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
@@ -109,7 +109,7 @@ const Profile: React.FC<ProfileProps> = ({
})
// Fetch writings in background (no limit for single user profile)
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
fetchBlogPostsFromAuthors(relayPool, [pubkey], getActiveRelayUrls(relayPool), undefined, null)
.then(writings => {
writings.forEach(w => eventStore.add(w.event))
})

View File

@@ -3,7 +3,6 @@ import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
@@ -124,8 +123,8 @@ class ArchiveController {
try {
// Stream kind:17 and kind:7 in parallel
const [kind17, kind7] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
])
if (startGen !== this.generation) return
@@ -138,7 +137,7 @@ class ArchiveController {
if (this.pendingEventIds.size > 0) {
// Fetch referenced articles (kind:30023) and map event IDs to naddr
const ids = Array.from(this.pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
console.log('[archive] fetched articles for mapping:', articleEvents.length)
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]

View File

@@ -1,7 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { ARCHIVE_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
@@ -30,8 +29,8 @@ export async function fetchReadArticles(
try {
// Fetch kind:7 and kind:17 reactions in parallel
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }),
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] })
])
const readArticles: ReadArticle[] = []
@@ -115,8 +114,7 @@ export async function fetchReadArticlesWithData(
const articleEvents = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], ids: eventIds },
{ relayUrls: RELAYS }
{ kinds: [KINDS.BlogPost], ids: eventIds }
)
// Deduplicate article events by ID

View File

@@ -2,8 +2,8 @@ import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
import { EventFactory } from 'applesauce-factory'
import { getActiveRelayUrls } from './relayManager'
const ARCHIVE_EMOJI = '📚'
@@ -49,7 +49,7 @@ export async function createEventReaction(
// Publish to relays
await relayPool.publish(RELAYS, signed)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
@@ -99,7 +99,7 @@ export async function createWebsiteReaction(
// Publish to relays
await relayPool.publish(RELAYS, signed)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
@@ -122,7 +122,7 @@ export async function deleteReaction(
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
await relayPool.publish(RELAYS, signed)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
}
@@ -146,7 +146,7 @@ export async function hasMarkedEventAsRead(
}
const events$ = relayPool
.req(RELAYS, filter)
.req(getActiveRelayUrls(relayPool), filter)
.pipe(
onlyEvents(),
completeOnEose(),
@@ -199,7 +199,7 @@ export async function hasMarkedWebsiteAsRead(
}
const events$ = relayPool
.req(RELAYS, filter)
.req(getActiveRelayUrls(relayPool), filter)
.pipe(
onlyEvents(),
completeOnEose(),

View File

@@ -3,7 +3,6 @@ import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { processReadingProgress } from './readingDataProcessor'
import { ReadItem } from './readsService'
import { ARCHIVE_EMOJI } from './reactionService'
@@ -233,7 +232,7 @@ class ReadingProgressController {
queryEvents(relayPool, {
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
}, { relayUrls: RELAYS })
})
.then((relayEvents) => {
if (startGeneration !== this.generation) return
console.log('[readingProgress] Got reading progress from relays:', relayEvents.length)
@@ -343,8 +342,8 @@ class ReadingProgressController {
// Fire queries with onEvent callbacks for streaming behavior
const [kind17Events, kind7Events] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
])
if (generation !== this.generation) return
@@ -356,7 +355,7 @@ class ReadingProgressController {
if (pendingEventIds.size > 0) {
// Fetch referenced 30023 events, streaming not required here
const ids = Array.from(pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
const eventIdToNaddr = new Map<string, string>()
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]

View File

@@ -3,7 +3,6 @@ import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
@@ -44,7 +43,7 @@ export async function fetchAllReads(
try {
// Fetch all data sources in parallel
const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }),
fetchReadArticles(relayPool, userPubkey)
])
@@ -130,8 +129,7 @@ export async function fetchAllReads(
const events = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
{ relayUrls: RELAYS }
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers }
)
// Merge event data into ReadItems and emit

View File

@@ -0,0 +1,132 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
export interface UserRelayInfo {
url: string
mode?: 'read' | 'write' | 'both'
}
/**
* Loads user's relay list from kind 10002 (NIP-65)
*/
export async function loadUserRelayList(
relayPool: RelayPool,
pubkey: string
): Promise<UserRelayInfo[]> {
try {
const events = await queryEvents(relayPool, {
kinds: [10002],
authors: [pubkey]
})
if (events.length === 0) return []
// Get most recent event
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const relayListEvent = sortedEvents[0]
const relays: UserRelayInfo[] = []
for (const tag of relayListEvent.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1]
const mode = tag[2] as 'read' | 'write' | undefined
relays.push({
url,
mode: mode || 'both'
})
}
}
return relays
} catch (error) {
console.error('Failed to load user relay list:', error)
return []
}
}
/**
* Loads blocked relays from kind 10006 (NIP-51 mute list)
*/
export async function loadBlockedRelays(
relayPool: RelayPool,
pubkey: string
): Promise<string[]> {
try {
const events = await queryEvents(relayPool, {
kinds: [10006],
authors: [pubkey]
})
if (events.length === 0) return []
// Get most recent event
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const muteListEvent = sortedEvents[0]
const blocked: string[] = []
for (const tag of muteListEvent.tags) {
if (tag[0] === 'r' && tag[1]) {
blocked.push(tag[1])
}
}
return blocked
} catch (error) {
console.error('Failed to load blocked relays:', error)
return []
}
}
/**
* Computes final relay set by merging inputs and removing blocked relays
*/
export function computeRelaySet(params: {
hardcoded: string[]
bunker?: string[]
userList?: UserRelayInfo[]
blocked?: string[]
alwaysIncludeLocal: string[]
}): string[] {
const {
hardcoded,
bunker = [],
userList = [],
blocked = [],
alwaysIncludeLocal
} = params
const relaySet = new Set<string>()
const blockedSet = new Set(blocked)
// Helper to check if relay should be included
const shouldInclude = (url: string): boolean => {
// Always include local relays
if (alwaysIncludeLocal.includes(url)) return true
// Otherwise check if blocked
return !blockedSet.has(url)
}
// Add hardcoded relays
for (const url of hardcoded) {
if (shouldInclude(url)) relaySet.add(url)
}
// Add bunker relays
for (const url of bunker) {
if (shouldInclude(url)) relaySet.add(url)
}
// Add user relays (treating 'both' and 'read' as applicable for queries)
for (const relay of userList) {
if (shouldInclude(relay.url)) relaySet.add(relay.url)
}
// Always ensure local relays are present
for (const url of alwaysIncludeLocal) {
relaySet.add(url)
}
return Array.from(relaySet)
}

View File

@@ -0,0 +1,52 @@
import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers'
/**
* Local relays that are always included
*/
export const ALWAYS_LOCAL_RELAYS = [
'ws://localhost:10547',
'ws://localhost:4869'
]
/**
* Gets active relay URLs from the relay pool
*/
export function getActiveRelayUrls(relayPool: RelayPool): string[] {
const urls = Array.from(relayPool.relays.keys())
return prioritizeLocalRelays(urls)
}
/**
* Applies a new relay set to the pool: adds missing relays, removes extras
*/
export function applyRelaySetToPool(
relayPool: RelayPool,
finalUrls: string[]
): void {
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
const targetUrls = new Set(finalUrls)
// Add new relays
const toAdd = finalUrls.filter(url => !currentUrls.has(url))
if (toAdd.length > 0) {
relayPool.group(toAdd)
}
// Remove relays not in target (but always keep local relays)
const toRemove: string[] = []
for (const url of currentUrls) {
if (!targetUrls.has(url) && !ALWAYS_LOCAL_RELAYS.includes(url)) {
toRemove.push(url)
}
}
for (const url of toRemove) {
const relay = relayPool.relays.get(url)
if (relay) {
relay.close()
relayPool.relays.delete(url)
}
}
}

View File

@@ -1,9 +1,9 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
import { getActiveRelayUrls } from './relayManager'
/**
* Unified write helper: add event to EventStore, detect connectivity,
@@ -27,10 +27,13 @@ export async function publishEvent(
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
// Get active relay URLs from the pool
const activeRelays = getActiveRelayUrls(relayPool)
// Determine which relays we expect to succeed
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(isLocalRelay)
? activeRelays
: activeRelays.filter(isLocalRelay)
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
@@ -42,7 +45,7 @@ export async function publishEvent(
}
// Publish to all configured relays in the background (non-blocking)
relayPool.publish(RELAYS, event)
relayPool.publish(activeRelays, event)
.then(() => {
})
.catch((error) => {