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.
508 lines
18 KiB
508 lines
18 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 } from '@/types' |
|
import logger from '@/lib/logger' |
|
|
|
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 |
|
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) |
|
|
|
// 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) |
|
} |
|
|
|
/** |
|
* 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) |
|
if (parentEvent) { |
|
const authorRelayList = await client.fetchRelayList(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 { |
|
const relayList = await client.fetchRelayList(pubkey) |
|
// 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 fetch 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 { |
|
const relayList = await client.fetchRelayList(pubkey) |
|
const userRelays = relayList?.write || [] |
|
// Filter out local relays from other users |
|
return this.filterLocalRelaysFromOthers(userRelays) |
|
} catch (error) { |
|
logger.warn('Failed to fetch 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 |
|
*/ |
|
private async getPublicMessageRelays(context: RelaySelectionContext): Promise<string[]> { |
|
const { userWriteRelays, parentEvent, isPublicMessage, content, userPubkey } = context |
|
const relays = new Set<string>() |
|
|
|
try { |
|
// Add sender's write relays (outboxes) - fallback to fast write relays if no user relays |
|
const senderRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS |
|
senderRelays.forEach(url => { |
|
const normalized = normalizeUrl(url) |
|
if (normalized) { |
|
relays.add(normalized) |
|
} else { |
|
relays.add(url) |
|
} |
|
}) |
|
|
|
// Add receiver's read relays (inboxes) |
|
if (isPublicMessage && content && userPubkey) { |
|
// For new public messages, get mentioned users' read relays |
|
const mentions = await this.extractMentions(content, parentEvent) |
|
const mentionedPubkeys = mentions.filter(p => p !== userPubkey) |
|
|
|
if (mentionedPubkeys.length > 0) { |
|
const receiverRelayLists = await Promise.all( |
|
mentionedPubkeys.map(async (pubkey) => { |
|
try { |
|
const relayList = await client.fetchRelayList(pubkey) |
|
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 [] |
|
} |
|
}) |
|
) |
|
receiverRelayLists.flat().forEach(url => { |
|
const normalized = normalizeUrl(url) |
|
if (normalized) { |
|
relays.add(normalized) |
|
} else { |
|
relays.add(url) |
|
} |
|
}) |
|
} |
|
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { |
|
// For public message replies, get original sender's read relays (filter out their local relays) |
|
try { |
|
const senderRelayList = await client.fetchRelayList(parentEvent.pubkey) |
|
if (senderRelayList?.read) { |
|
const filteredRelays = this.filterLocalRelaysFromOthers(senderRelayList.read) |
|
filteredRelays.forEach(url => { |
|
const normalized = normalizeUrl(url) |
|
if (normalized) { |
|
relays.add(normalized) |
|
} else { |
|
relays.add(url) |
|
} |
|
}) |
|
} |
|
} catch (error) { |
|
logger.warn('Failed to fetch relay list for parent event', { parentPubkey: parentEvent.pubkey, error }) |
|
} |
|
} |
|
} catch (error) { |
|
logger.error('Failed to get public message relays', { error, parentEvent: context.parentEvent?.id }) |
|
} |
|
|
|
return Array.from(relays) |
|
} |
|
|
|
|
|
/** |
|
* 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
|
|
|