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.
 
 
 
 

827 lines
30 KiB

import { Event, kinds } from 'nostr-tools'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants'
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
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'
import nip66Service from '@/services/nip66.service'
import storage from '@/services/local-storage.service'
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[]
/** Random relays added to the selectable list; when setting is ON they are selected by default */
randomRelayUrls?: string[]
}
/** Display type for a relay in the publish relay selector */
export type RelaySourceType =
| 'local'
| 'relay_list'
| 'client_default'
| 'open_from'
| 'favorite'
| 'relay_set'
| 'contextual'
| 'randomly_selected'
export interface RelaySelectionResult {
selectableRelays: string[]
selectedRelays: string[]
description: string
/** Source type per relay URL (for UI labels). */
relayTypes: Record<string, RelaySourceType>
}
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 and their source types
const { relays: selectableRelays, relayTypes, randomRelayUrls } = await this.buildSelectableRelaysWithTypes(context)
// Step 2: Determine which relays should be selected (checked)
const contextWithRandom = { ...context, randomRelayUrls }
const selectedRelays = await this.determineSelectedRelays(contextWithRandom)
// Step 3: Generate description
const description = this.generateDescription(selectedRelays)
return {
selectableRelays,
selectedRelays,
description,
relayTypes
}
}
/**
* Build the list of all relays that can be selected, with a source type for each (first source wins).
* Always includes: user's write relays (or fast write fallback) + favorite relays + relay sets
* Plus contextual relays for replies and public messages.
*/
private async buildSelectableRelaysWithTypes(
context: RelaySelectionContext
): Promise<{ relays: string[]; relayTypes: Record<string, RelaySourceType>; randomRelayUrls: string[] }> {
const {
userWriteRelays,
favoriteRelays,
relaySets,
parentEvent,
isPublicMessage,
openFrom
} = context
const order: { url: string; type: RelaySourceType }[] = []
const seen = new Set<string>()
const addRelay = (url: string, type: RelaySourceType) => {
if (!url) return
const normalized = normalizeUrl(url)
if (normalized && !seen.has(normalized)) {
seen.add(normalized)
order.push({ url: normalized, type })
} else if (!normalized) {
logger.warn('Skipping invalid relay URL', { url })
}
}
// User's write relays (or fallback = client default)
const userRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS
const userType: RelaySourceType = userWriteRelays.length > 0 ? 'relay_list' : 'client_default'
userRelays.forEach((url) => addRelay(url, userType))
// Cache relays (local) – may duplicate user write; only add if not already present
const cacheRelays = userWriteRelays.filter((url) => isLocalNetworkUrl(url))
cacheRelays.forEach((url) => addRelay(url, 'local'))
favoriteRelays.forEach((url) => addRelay(url, 'favorite'))
relaySets.forEach((set) => {
set.relayUrls.forEach((url) => addRelay(url, 'relay_set'))
})
if (parentEvent || isPublicMessage) {
const contextualRelays = await this.getContextualRelays(context)
contextualRelays.forEach((url) => addRelay(url, 'contextual'))
}
if (openFrom && openFrom.length > 0) {
openFrom.forEach((url) => addRelay(url, 'open_from'))
}
// Random relays: prefer session-proven fast relays, then fill with random from rest (selection only random between sessions)
const randomRelayUrls: string[] = []
if (typeof window !== 'undefined') {
try {
const publicLively = await nip66Service.getPublicLivelyRelayUrls()
/** Session OK relays first so they stay candidates even if absent from NIP-66 lively list */
const sessionBoost = client.getSessionSuccessfulPublishRelayUrlsForRandomPool()
const existing = new Set(order.map((o) => o.url))
const seenCand = new Set<string>()
const candidates: string[] = []
for (const u of [...sessionBoost, ...publicLively]) {
const n = normalizeUrl(u) || u
if (!n || existing.has(n) || seenCand.has(n)) continue
seenCand.add(n)
candidates.push(n)
}
const preferred = client.getPreferredRelaysForRandom(candidates, RANDOM_PUBLISH_RELAY_COUNT)
preferred.forEach((url) => {
const normalized = normalizeUrl(url) || url
addRelay(normalized, 'randomly_selected')
randomRelayUrls.push(normalized)
})
} catch {
// ignore
}
}
const deduplicatedRelays = order.map((o) => o.url)
const filtered = this.filterBlockedRelays(deduplicatedRelays, context.blockedRelays)
const relayTypes: Record<string, RelaySourceType> = {}
order.forEach(({ url, type }) => {
if (filtered.includes(url)) relayTypes[url] = type
})
return { relays: filtered, relayTypes, randomRelayUrls }
}
/**
* 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 hints from the kind 11 + user's outboxes + local relays + thecitadel
else if (parentEvent && (parentEvent.kind === ExtendedKind.DISCUSSION || parentEvent.kind === ExtendedKind.COMMENT)) {
selectedRelays = await this.getDiscussionReplyRelays(context)
}
// 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))
}
// When "add random relays" setting is ON, include random relays in selected by default; when OFF they are still in the list but unchecked
if (context.randomRelayUrls?.length && storage.getAddRandomRelaysToPublish()) {
selectedRelays = [...selectedRelays, ...context.randomRelayUrls]
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 all relay hints from a kind 11 discussion event
* Returns all relays where the event was seen (excluding local relays)
*/
private getDiscussionRelayHints(discussionEventId: string): string[] {
const eventHints = client.getEventHints(discussionEventId)
return eventHints.map(url => normalizeUrl(url) || url).filter(Boolean)
}
/**
* Get relays for discussion replies (kind 11 or kind 1111)
* Includes: relay hints from kind 11, wss://thecitadel.nostr1.com, user's outboxes, and local relays
*/
private async getDiscussionReplyRelays(context: RelaySelectionContext): Promise<string[]> {
const { parentEvent, userWriteRelays, userPubkey, blockedRelays } = context
if (!parentEvent) return []
const relayUrls = new Set<string>()
// Step 1: Get relay hints from the kind 11 event
let discussionEventId: string | null = null
if (parentEvent.kind === ExtendedKind.COMMENT) {
// For kind 1111 (COMMENT): get root kind 11 event ID from E tag
const ETag = parentEvent.tags.find(tag => tag[0] === 'E')
if (ETag && ETag[1]) {
discussionEventId = ETag[1]
} else {
// Fallback to lowercase e tag
const eTag = parentEvent.tags.find(tag => tag[0] === 'e')
if (eTag && eTag[1]) {
discussionEventId = eTag[1]
}
}
} else if (parentEvent.kind === ExtendedKind.DISCUSSION) {
// For kind 11 (DISCUSSION): use the event itself
discussionEventId = parentEvent.id
}
// Get all relay hints from the kind 11 event
if (discussionEventId) {
const discussionHints = this.getDiscussionRelayHints(discussionEventId)
discussionHints.forEach(url => relayUrls.add(url))
}
// Step 2: Add wss://thecitadel.nostr1.com
const thecitadelUrl = normalizeUrl('wss://thecitadel.nostr1.com')
if (thecitadelUrl) {
relayUrls.add(thecitadelUrl)
}
// Step 3: Add user's outboxes (write relays from kind 10002)
if (userWriteRelays.length > 0) {
userWriteRelays.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) {
relayUrls.add(normalized)
}
})
} else if (userPubkey) {
// Fetch user's relay list if not provided
try {
const relayList = await this.getCachedRelayList(userPubkey)
if (relayList?.write) {
relayList.write.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) {
relayUrls.add(normalized)
}
})
}
} catch (error) {
logger.warn('Failed to fetch user relay list for discussion reply', { error, userPubkey })
}
}
// Step 4: Add local relays (cache relays from kind 10432)
if (userPubkey) {
try {
const cacheRelayEvent = await indexedDb.getReplaceableEvent(userPubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayEvent) {
cacheRelayEvent.tags.forEach(tag => {
if (tag[0] === 'relay' && tag[1]) {
const normalized = normalizeUrl(tag[1])
if (normalized) {
relayUrls.add(normalized)
}
}
})
}
} catch (error) {
logger.warn('Failed to fetch cache relays for discussion reply', { error, userPubkey })
}
}
// Step 5: Convert to array, normalize, and deduplicate
const normalizedRelays = Array.from(relayUrls)
.map(url => normalizeUrl(url))
.filter((url): url is string => !!url)
const deduplicatedRelays = Array.from(new Set(normalizedRelays))
// Step 6: Filter out blocked relays
return this.filterBlockedRelays(deduplicatedRelays, blockedRelays)
}
/**
* 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_URI_FOR_REPLY_PUBKEYS_REGEX)
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