You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

699 lines
25 KiB

import { Event, kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
import { FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { TRelaySet, TRelayList } from '@/types'
import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service'
import { getRelayListFromEvent } from '@/lib/event-metadata'
export interface RelaySelectionContext {
// User's own relays
userWriteRelays: string[]
userReadRelays: string[]
favoriteRelays: string[]
blockedRelays: string[]
relaySets: TRelaySet[]
// Post context
parentEvent?: Event
isPublicMessage?: boolean
content?: string
mentions?: string[] // Pre-extracted mentions (for PMs)
userPubkey?: string
openFrom?: string[]
}
export interface RelaySelectionResult {
selectableRelays: string[]
selectedRelays: string[]
description: string
}
class RelaySelectionService {
/**
* Filter out local network relays from other users' relay lists
* We should only use our own local relays, not other users' local relays
*/
private filterLocalRelaysFromOthers(relays: string[], isOwnRelays: boolean = false): string[] {
if (isOwnRelays) {
// For our own relays, keep all of them including local ones
return relays
}
// For other users' relays, filter out local network relays
return relays.filter(relay => !isLocalNetworkUrl(relay))
}
/**
* Main entry point for relay selection logic
*/
async selectRelays(context: RelaySelectionContext): Promise<RelaySelectionResult> {
// Step 1: Build the list of selectable relays
const selectableRelays = await this.buildSelectableRelays(context)
// Step 2: Determine which relays should be selected (checked)
const selectedRelays = await this.determineSelectedRelays(context)
// Step 3: Generate description
const description = this.generateDescription(selectedRelays)
return {
selectableRelays,
selectedRelays,
description
}
}
/**
* Build the list of all relays that can be selected
* Always includes: user's write relays (or fast write fallback) + favorite relays + relay sets
* Plus contextual relays for replies and public messages
*/
private async buildSelectableRelays(context: RelaySelectionContext): Promise<string[]> {
const {
userWriteRelays,
favoriteRelays,
relaySets,
parentEvent,
isPublicMessage,
openFrom
} = context
const selectableRelays = new Set<string>()
// Helper function to safely add normalized URLs
const addRelay = (url: string) => {
if (!url) return
const normalized = normalizeUrl(url)
if (normalized) {
selectableRelays.add(normalized)
} else {
// If normalization fails or returns empty (invalid URL), skip it
logger.warn('Skipping invalid relay URL', { url })
}
}
// Always include user's write relays (or fallback to fast write relays)
const userRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS
userRelays.forEach(addRelay)
// Explicitly ensure cache relays (local network URLs) are included in selectable relays
// This ensures they show up even if there's a timing issue with relay list updates
const cacheRelays = userWriteRelays.filter(url => isLocalNetworkUrl(url))
cacheRelays.forEach(addRelay)
// Always include favorite relays
favoriteRelays.forEach(addRelay)
// Always include relays from relay sets
relaySets.forEach(set => {
set.relayUrls.forEach(addRelay)
})
// Add contextual relays for replies and public messages
if (parentEvent || isPublicMessage) {
const contextualRelays = await this.getContextualRelays(context)
contextualRelays.forEach(addRelay)
}
// If called with specific relay URLs (e.g., from openFrom), include those
if (openFrom && openFrom.length > 0) {
openFrom.forEach(addRelay)
}
// Filter out blocked relays and return deduplicated list
const deduplicatedRelays = Array.from(selectableRelays).filter(Boolean)
return this.filterBlockedRelays(deduplicatedRelays, context.blockedRelays)
}
/**
* Validate that a URL is a valid, non-empty relay URL
*/
private isValidRelayUrl(url: string | undefined | null): url is string {
return !!(url && typeof url === 'string' && url.trim() !== '' && url !== 'ws://' && url !== 'wss://')
}
/**
* Get relay list from IndexedDB cache (kind 10002 and 10432 merged)
* If not in cache, fetch from relays before returning empty
* This avoids fetching from relays every time, but ensures we have data when needed
*/
private async getCachedRelayList(pubkey: string): Promise<TRelayList | null> {
try {
// Get both kind 10002 (relay list) and kind 10432 (cache relays) from IndexedDB
const [relayListEvent, cacheRelayListEvent] = await Promise.all([
indexedDb.getReplaceableEvent(pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
])
let relayList: TRelayList
// If no cached relay list event, fetch from relays (which will also cache it)
if (!relayListEvent) {
try {
relayList = await client.fetchRelayList(pubkey)
} catch (error) {
logger.warn('Failed to fetch relay list from relays', { error, pubkey })
relayList = {
write: [],
read: [],
originalRelays: []
}
}
} else {
relayList = getRelayListFromEvent(relayListEvent)
}
// Merge cache relays (kind 10432) into the relay list
if (cacheRelayListEvent) {
const cacheRelayList = getRelayListFromEvent(cacheRelayListEvent)
// Filter out invalid/empty URLs before merging
const validCacheRead = cacheRelayList.read.filter(this.isValidRelayUrl)
const validCacheWrite = cacheRelayList.write.filter(this.isValidRelayUrl)
const validRelayRead = relayList.read.filter(this.isValidRelayUrl)
const validRelayWrite = relayList.write.filter(this.isValidRelayUrl)
// Merge read relays - cache relays first, then others
const mergedRead = [...validCacheRead, ...validRelayRead]
const mergedWrite = [...validCacheWrite, ...validRelayWrite]
const mergedOriginalRelays = new Map<string, { url: string; scope: 'read' | 'write' | 'both' }>()
// Add cache relay original relays first (prioritized)
cacheRelayList.originalRelays.forEach(relay => {
mergedOriginalRelays.set(relay.url, relay)
})
// Then add regular relay original relays
relayList.originalRelays.forEach(relay => {
if (!mergedOriginalRelays.has(relay.url)) {
mergedOriginalRelays.set(relay.url, relay)
}
})
// Deduplicate while preserving order (cache relays first)
return {
write: Array.from(new Set(mergedWrite)),
read: Array.from(new Set(mergedRead)),
originalRelays: Array.from(mergedOriginalRelays.values())
}
}
return relayList
} catch (error) {
logger.warn('Failed to get cached relay list from IndexedDB', { error, pubkey })
return null
}
}
/**
* Get contextual relays based on the type of post
*/
private async getContextualRelays(context: RelaySelectionContext): Promise<string[]> {
const { parentEvent, isPublicMessage, content, userPubkey } = context
const contextualRelays = new Set<string>()
try {
// For replies (any kind) and public messages
if (parentEvent || isPublicMessage) {
// Get the replied-to author's read relays (filter out their local relays)
// Use cached version from IndexedDB instead of fetching from relays
if (parentEvent) {
const authorRelayList = await this.getCachedRelayList(parentEvent.pubkey)
if (authorRelayList?.read) {
const filteredRelays = this.filterLocalRelaysFromOthers(authorRelayList.read)
filteredRelays.slice(0, 4).forEach(url => contextualRelays.add(url))
}
}
// Get relay hint from where the event was discovered
if (parentEvent) {
const eventHints = client.getEventHints(parentEvent.id)
eventHints.forEach(url => contextualRelays.add(url))
}
// For replies and public messages, get mentioned users' relays
if (userPubkey) {
let mentions: string[] = []
// Always include parent event author for replies
if (parentEvent) {
mentions.push(parentEvent.pubkey)
}
// Extract additional mentions from content if available
if (content) {
const contentMentions = await this.extractMentions(content, parentEvent)
mentions = [...new Set([...mentions, ...contentMentions])] // deduplicate
}
const mentionedPubkeys = mentions.filter(p => p !== userPubkey)
if (mentionedPubkeys.length > 0) {
const mentionRelayLists = await Promise.all(
mentionedPubkeys.map(async (pubkey) => {
try {
// Use cached version from IndexedDB instead of fetching from relays
const relayList = await this.getCachedRelayList(pubkey)
if (!relayList) return []
// Use write relays for replies, read relays for public messages
const relayType = isPublicMessage ? 'read' : 'write'
const userRelays = relayList[relayType] || []
// Filter out local relays from other users
return this.filterLocalRelaysFromOthers(userRelays)
} catch (error) {
logger.warn('Failed to get cached relay list', { pubkey, error })
return []
}
})
)
mentionRelayLists.flat().forEach(url => contextualRelays.add(url))
}
}
}
} catch (error) {
logger.error('Failed to get contextual relays', { error })
}
return Array.from(contextualRelays)
}
/**
* Determine which relays should be selected (checked) based on the context
*/
private async determineSelectedRelays(
context: RelaySelectionContext
): Promise<string[]> {
const {
userWriteRelays,
parentEvent,
isPublicMessage,
openFrom,
content,
userPubkey
} = context
let selectedRelays: string[] = []
// If called with specific relay URLs, use those
if (openFrom && openFrom.length > 0) {
selectedRelays = openFrom.map(url => normalizeUrl(url) || url).filter(Boolean)
// Deduplicate the selected relays
selectedRelays = Array.from(new Set(selectedRelays))
}
// For discussion replies, use relay hint from the kind 11 at the top of the thread
else if (parentEvent && (parentEvent.kind === ExtendedKind.DISCUSSION || parentEvent.kind === ExtendedKind.COMMENT)) {
const discussionRelay = this.getDiscussionRelayHint(parentEvent)
if (discussionRelay) {
selectedRelays = [discussionRelay]
}
}
// For public messages, use sender outboxes + receiver inboxes
else if (isPublicMessage || (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE)) {
selectedRelays = await this.getPublicMessageRelays(context)
}
// For regular replies, use user's write relays + mention relays
else if (parentEvent && this.isRegularReply(parentEvent)) {
// Get user's write relays
const userRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS
selectedRelays = userRelays.map(url => normalizeUrl(url) || url).filter(Boolean)
// Deduplicate the selected relays
selectedRelays = Array.from(new Set(selectedRelays))
// Add mention relays
if (userPubkey) {
let mentions: string[] = []
// Always include parent event author for replies
if (parentEvent) {
mentions.push(parentEvent.pubkey)
}
// Extract additional mentions from content if available
if (content) {
const contentMentions = await this.extractMentions(content, parentEvent)
mentions = [...new Set([...mentions, ...contentMentions])] // deduplicate
}
const mentionedPubkeys = mentions.filter(p => p !== userPubkey)
if (mentionedPubkeys.length > 0) {
const mentionRelayLists = await Promise.all(
mentionedPubkeys.map(async (pubkey) => {
try {
// Use cached version from IndexedDB instead of fetching from relays
const relayList = await this.getCachedRelayList(pubkey)
if (!relayList) return []
const userRelays = relayList.write || []
// Filter out local relays from other users
return this.filterLocalRelaysFromOthers(userRelays)
} catch (error) {
logger.warn('Failed to get cached relay list', { pubkey, error })
return []
}
})
)
const mentionRelays = mentionRelayLists.flat().map(url => normalizeUrl(url) || url).filter(Boolean)
selectedRelays = [...selectedRelays, ...mentionRelays]
// Deduplicate after adding mention relays
selectedRelays = Array.from(new Set(selectedRelays))
}
}
}
// Default: user's write relays (or fallback to fast write relays if no user relays)
else {
const defaultRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS
selectedRelays = defaultRelays.map(url => normalizeUrl(url) || url).filter(Boolean)
// Deduplicate the selected relays
selectedRelays = Array.from(new Set(selectedRelays))
}
// ALWAYS include cache relays (local network relays) in selected relays
// Cache relays are important for offline functionality
const cacheRelays = userWriteRelays.filter(url => isLocalNetworkUrl(url))
if (cacheRelays.length > 0) {
selectedRelays = [...selectedRelays, ...cacheRelays]
// Deduplicate after adding cache relays
selectedRelays = Array.from(new Set(selectedRelays))
}
// Filter out blocked relays
return this.filterBlockedRelays(selectedRelays, context.blockedRelays)
}
/**
* Get relays for public messages: sender outboxes + receiver inboxes
* Only includes outboxes from sender and inboxes from all recipients
* Normalized and deduplicated. If more than 10, limits to one per member,
* preferring relays that multiple people have.
*/
private async getPublicMessageRelays(context: RelaySelectionContext): Promise<string[]> {
const { userWriteRelays, parentEvent, isPublicMessage, content, mentions, userPubkey } = context
// Map to track which relays belong to which members
const relayToMembers = new Map<string, Set<string>>()
const allMembers = new Set<string>()
try {
// Get sender's outboxes (write relays)
if (userPubkey) {
allMembers.add(userPubkey)
let senderRelays = userWriteRelays
// If userWriteRelays is empty, try to fetch the user's relay list
if (senderRelays.length === 0) {
try {
const userRelayList = await this.getCachedRelayList(userPubkey)
if (userRelayList?.write && userRelayList.write.length > 0) {
senderRelays = userRelayList.write
} else {
// Only fall back to fast write relays if we truly have no user relays
senderRelays = FAST_WRITE_RELAY_URLS
}
} catch (error) {
logger.warn('Failed to fetch user relay list for PM', { error, userPubkey })
// Fall back to fast write relays if fetch fails
senderRelays = FAST_WRITE_RELAY_URLS
}
}
senderRelays.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) {
if (!relayToMembers.has(normalized)) {
relayToMembers.set(normalized, new Set())
}
relayToMembers.get(normalized)!.add(userPubkey)
}
})
}
// Get recipients and their inboxes (read relays)
let recipientPubkeys: string[] = []
if (isPublicMessage && userPubkey) {
// For new public messages, use provided mentions or extract from content
if (mentions && mentions.length > 0) {
recipientPubkeys = mentions.filter(p => p !== userPubkey)
} else if (content) {
// Fallback to extracting from content if mentions not provided
const extractedMentions = await this.extractMentions(content, parentEvent)
recipientPubkeys = extractedMentions.filter(p => p !== userPubkey)
}
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
// For public message replies, get all recipients from parent event
// Include original sender and all p tags
recipientPubkeys = [parentEvent.pubkey]
parentEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'p' && tagValue && tagValue !== userPubkey) {
recipientPubkeys.push(tagValue)
}
})
// Deduplicate
recipientPubkeys = Array.from(new Set(recipientPubkeys))
}
// Fetch read relays (inboxes) for all recipients
if (recipientPubkeys.length > 0) {
const recipientRelayLists = await Promise.all(
recipientPubkeys.map(async (pubkey) => {
try {
allMembers.add(pubkey)
// Use cached version from IndexedDB
const relayList = await this.getCachedRelayList(pubkey)
if (!relayList) return []
const userRelays = relayList.read || []
// Filter out local relays from other users
return this.filterLocalRelaysFromOthers(userRelays)
} catch (error) {
logger.warn('Failed to fetch relay list', { pubkey, error })
return []
}
})
)
// Track which relays belong to which recipients
recipientRelayLists.forEach((relays, index) => {
const pubkey = recipientPubkeys[index]
relays.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) {
if (!relayToMembers.has(normalized)) {
relayToMembers.set(normalized, new Set())
}
relayToMembers.get(normalized)!.add(pubkey)
}
})
})
}
// Build final relay list
const relays: string[] = []
// If we have 10 or fewer relays, use all of them
if (relayToMembers.size <= 10) {
relays.push(...Array.from(relayToMembers.keys()))
} else {
// More than 10 relays - need to limit to one per member
// Prefer relays that multiple people have
// Sort relays by number of members (descending), then by URL for stability
const sortedRelays = Array.from(relayToMembers.entries())
.sort((a, b) => {
const aCount = a[1].size
const bCount = b[1].size
if (aCount !== bCount) {
return bCount - aCount // Prefer relays with more members
}
return a[0].localeCompare(b[0]) // Stable sort by URL
})
// Track which members already have a relay selected
const selectedForMember = new Map<string, string>()
// First pass: assign relays that multiple people have
for (const [relayUrl, members] of sortedRelays) {
if (members.size > 1) {
// This relay is used by multiple people - add it
relays.push(relayUrl)
// Mark all members as having a relay
members.forEach(member => {
selectedForMember.set(member, relayUrl)
})
}
}
// Second pass: ensure each member has at least one relay
for (const [relayUrl, members] of sortedRelays) {
if (relays.length >= 10) break
// Check if any member still needs a relay
const needsRelay = Array.from(members).some(member => !selectedForMember.has(member))
if (needsRelay) {
relays.push(relayUrl)
members.forEach(member => {
if (!selectedForMember.has(member)) {
selectedForMember.set(member, relayUrl)
}
})
}
}
}
// Normalize and deduplicate final list
const normalizedRelays = relays
.map(url => normalizeUrl(url))
.filter((url): url is string => !!url)
return Array.from(new Set(normalizedRelays))
} catch (error) {
logger.error('Failed to get public message relays', { error, parentEvent: context.parentEvent?.id })
// Fallback to sender's write relays
const senderRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS
return senderRelays.map(url => normalizeUrl(url) || url).filter(Boolean)
}
}
/**
* Check if this is a regular reply (Kind 1 or Kind 1111, not to Kind 11)
*/
private isRegularReply(parentEvent: Event): boolean {
return (parentEvent.kind === kinds.ShortTextNote || parentEvent.kind === ExtendedKind.COMMENT) &&
parentEvent.kind !== ExtendedKind.DISCUSSION
}
/**
* Get relay hint from discussion events
*/
private getDiscussionRelayHint(parentEvent: Event): string | null {
// For kind 1111 (COMMENT): look for 'E' tag which points to the root event
if (parentEvent.kind === ExtendedKind.COMMENT) {
const ETag = parentEvent.tags.find(tag => tag[0] === 'E')
if (ETag && ETag[2]) {
return normalizeUrl(ETag[2]) || ETag[2]
}
// If no 'E' tag, check lowercase 'e' tag for parent event
const eTag = parentEvent.tags.find(tag => tag[0] === 'e')
if (eTag && eTag[2]) {
return normalizeUrl(eTag[2]) || eTag[2]
}
} else if (parentEvent.kind === ExtendedKind.DISCUSSION) {
// For kind 11 (DISCUSSION): get relay hint from where it was found
const eventHints = client.getEventHints(parentEvent.id)
if (eventHints.length > 0) {
return normalizeUrl(eventHints[0]) || eventHints[0]
}
}
return null
}
/**
* Extract mentions from content (simplified version of the existing extractMentions)
*/
private async extractMentions(content: string, parentEvent?: Event): Promise<string[]> {
const pubkeys: string[] = []
// Always include parent event author if there's a parent event
if (parentEvent) {
pubkeys.push(parentEvent.pubkey)
}
// Extract nostr addresses from content
const matches = content.match(
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
)
if (matches) {
for (const match of matches) {
try {
const { nip19 } = await import('nostr-tools')
const id = match.split(':')[1]
const { type, data } = nip19.decode(id)
if (type === 'nprofile') {
if (!pubkeys.includes(data.pubkey)) {
pubkeys.push(data.pubkey)
}
} else if (type === 'npub') {
if (!pubkeys.includes(data)) {
pubkeys.push(data)
}
} else if (['nevent', 'note'].includes(type)) {
const event = await client.fetchEvent(id)
if (event && !pubkeys.includes(event.pubkey)) {
pubkeys.push(event.pubkey)
}
}
} catch (error) {
logger.error('Failed to decode nostr address', { error, match })
}
}
}
// Add related pubkeys from parent event tags
if (parentEvent) {
parentEvent.tags.forEach(([tagName, tagValue]) => {
if (['p', 'P'].includes(tagName) && tagValue && !pubkeys.includes(tagValue)) {
pubkeys.push(tagValue)
}
})
}
return pubkeys
}
/**
* Generate description for the selected relays
*/
private generateDescription(selectedRelays: string[]): string {
if (selectedRelays.length === 0) {
return 'No relays selected'
}
if (selectedRelays.length === 1) {
return this.simplifyUrl(selectedRelays[0])
}
return `${selectedRelays.length} relays`
}
/**
* Simplify URL for display
*/
private simplifyUrl(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.hostname
} catch {
return url
}
}
/**
* Filter out blocked relays from a list
*/
private filterBlockedRelays(relays: string[], blockedRelays: string[]): string[] {
if (!blockedRelays || blockedRelays.length === 0) {
return relays
}
// Helper function to safely normalize URLs
const safeNormalize = (url: string): string => {
const normalized = normalizeUrl(url)
return normalized || url
}
const normalizedBlocked = blockedRelays.map(safeNormalize)
return relays.filter(relay => {
const normalizedRelay = safeNormalize(relay)
return !normalizedBlocked.includes(normalizedRelay)
})
}
}
const relaySelectionService = new RelaySelectionService()
export default relaySelectionService