17 changed files with 1174 additions and 304 deletions
@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
|
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Button } from '../ui/button' |
||||
import { Input } from '../ui/input' |
||||
import { Loader2, Check } from 'lucide-react' |
||||
|
||||
export default function AddBlockedRelay() { |
||||
const { t } = useTranslation() |
||||
const { blockedRelays, addBlockedRelays } = useFavoriteRelays() |
||||
const [input, setInput] = useState('') |
||||
const [errorMsg, setErrorMsg] = useState('') |
||||
const [successMsg, setSuccessMsg] = useState('') |
||||
const [isLoading, setIsLoading] = useState(false) |
||||
|
||||
const saveRelay = async () => { |
||||
if (!input || isLoading) return |
||||
const normalizedUrl = normalizeUrl(input) |
||||
if (!normalizedUrl) { |
||||
setErrorMsg(t('Invalid URL')) |
||||
setSuccessMsg('') |
||||
return |
||||
} |
||||
if (blockedRelays.includes(normalizedUrl)) { |
||||
setErrorMsg(t('Already blocked')) |
||||
setSuccessMsg('') |
||||
return |
||||
} |
||||
|
||||
setIsLoading(true) |
||||
setErrorMsg('') |
||||
setSuccessMsg('') |
||||
|
||||
try { |
||||
await addBlockedRelays([normalizedUrl]) |
||||
setInput('') |
||||
setSuccessMsg(t('Relay blocked successfully')) |
||||
setTimeout(() => setSuccessMsg(''), 3000) |
||||
} catch (error) { |
||||
console.error('Failed to block relay:', error) |
||||
setErrorMsg(t('Failed to block relay. Please try again.')) |
||||
} finally { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
|
||||
const handleNewRelayInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
||||
setInput(e.target.value) |
||||
setErrorMsg('') |
||||
setSuccessMsg('') |
||||
} |
||||
|
||||
const handleNewRelayInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { |
||||
if (e.key === 'Enter') { |
||||
e.preventDefault() |
||||
saveRelay() |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-1"> |
||||
<div className="text-muted-foreground font-semibold select-none">{t('Block Relay')}</div> |
||||
<div className="flex gap-2 items-center"> |
||||
<Input |
||||
placeholder={t('Add a relay to block')} |
||||
value={input} |
||||
onChange={handleNewRelayInputChange} |
||||
onKeyDown={handleNewRelayInputKeyDown} |
||||
className={errorMsg ? 'border-destructive' : successMsg ? 'border-green-500' : ''} |
||||
disabled={isLoading} |
||||
/> |
||||
<Button onClick={saveRelay} disabled={isLoading || !input.trim()}> |
||||
{isLoading ? ( |
||||
<> |
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> |
||||
{t('Blocking...')} |
||||
</> |
||||
) : ( |
||||
t('Block') |
||||
)} |
||||
</Button> |
||||
</div> |
||||
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>} |
||||
{successMsg && ( |
||||
<div className="text-green-600 dark:text-green-400 text-sm pl-8 flex items-center gap-1"> |
||||
<Check className="h-3 w-3" /> |
||||
{successMsg} |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
import { toRelay } from '@/lib/link' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { X, Loader2 } from 'lucide-react' |
||||
import { useState } from 'react' |
||||
import RelayIcon from '../RelayIcon' |
||||
import { Button } from '../ui/button' |
||||
|
||||
export default function BlockedRelayItem({ relay }: { relay: string }) { |
||||
const { push } = useSecondaryPage() |
||||
const { deleteBlockedRelays } = useFavoriteRelays() |
||||
const [isLoading, setIsLoading] = useState(false) |
||||
|
||||
const handleUnblock = async (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
if (isLoading) return |
||||
|
||||
setIsLoading(true) |
||||
try { |
||||
await deleteBlockedRelays([relay]) |
||||
} catch (error) { |
||||
console.error('Failed to unblock relay:', error) |
||||
} finally { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className="relative group clickable flex gap-2 border rounded-lg p-2 pr-2.5 items-center justify-between select-none" |
||||
onClick={() => push(toRelay(relay))} |
||||
> |
||||
<div className="flex items-center gap-2 flex-1"> |
||||
<RelayIcon url={relay} /> |
||||
<div className="flex-1 w-0 truncate font-semibold">{relay}</div> |
||||
</div> |
||||
<Button |
||||
variant="ghost" |
||||
size="sm" |
||||
onClick={handleUnblock} |
||||
disabled={isLoading} |
||||
className="h-8 w-8 p-0" |
||||
> |
||||
{isLoading ? ( |
||||
<Loader2 className="h-4 w-4 animate-spin" /> |
||||
) : ( |
||||
<X className="h-4 w-4" /> |
||||
)} |
||||
</Button> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useTranslation } from 'react-i18next' |
||||
import BlockedRelayItem from './BlockedRelayItem' |
||||
|
||||
export default function BlockedRelayList() { |
||||
const { t } = useTranslation() |
||||
const { blockedRelays } = useFavoriteRelays() |
||||
|
||||
if (blockedRelays.length === 0) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-2"> |
||||
<div className="text-muted-foreground font-semibold select-none">{t('Blocked Relays')}</div> |
||||
<div className="grid gap-2"> |
||||
{blockedRelays.map((relay) => ( |
||||
<BlockedRelayItem key={relay} relay={relay} /> |
||||
))} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Checkbox } from '@/components/ui/checkbox' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { getRelaysFromNip07Extension, verifyNip05 } from '@/lib/nip05' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { TMailboxRelay } from '@/types' |
||||
import { Loader2, Check, AlertCircle } from 'lucide-react' |
||||
import { useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import RelayIcon from '../RelayIcon' |
||||
|
||||
interface DiscoveredRelay { |
||||
url: string |
||||
source: 'nip05' | 'nip07' | 'bunker' |
||||
selected: boolean |
||||
} |
||||
|
||||
export default function DiscoveredRelays({ onAdd }: { onAdd: (relays: TMailboxRelay[]) => void }) { |
||||
const { t } = useTranslation() |
||||
const { profile, account } = useNostr() |
||||
const [discoveredRelays, setDiscoveredRelays] = useState<DiscoveredRelay[]>([]) |
||||
const [isLoading, setIsLoading] = useState(false) |
||||
const [isAdding, setIsAdding] = useState(false) |
||||
const [successMsg, setSuccessMsg] = useState('') |
||||
const [errorMsg, setErrorMsg] = useState('') |
||||
|
||||
useEffect(() => { |
||||
discoverRelays() |
||||
}, [profile?.nip05, account?.pubkey, account?.signerType]) |
||||
|
||||
const discoverRelays = async () => { |
||||
if (!account?.pubkey) return |
||||
|
||||
setIsLoading(true) |
||||
setErrorMsg('') |
||||
const discovered = new Map<string, DiscoveredRelay>() |
||||
|
||||
try { |
||||
// Try to get relays from NIP-05
|
||||
if (profile?.nip05) { |
||||
try { |
||||
const nip05Result = await verifyNip05(profile.nip05, account.pubkey) |
||||
if (nip05Result.isVerified && nip05Result.relays) { |
||||
nip05Result.relays.forEach(url => { |
||||
const normalized = normalizeUrl(url) |
||||
if (normalized && !discovered.has(normalized)) { |
||||
discovered.set(normalized, { |
||||
url: normalized, |
||||
source: 'nip05', |
||||
selected: true |
||||
}) |
||||
} |
||||
}) |
||||
} |
||||
} catch (error) { |
||||
console.log('Could not fetch relays from NIP-05:', error) |
||||
} |
||||
} |
||||
|
||||
// Try to get relays from NIP-07 extension
|
||||
if (account.signerType === 'nip-07') { |
||||
try { |
||||
const extensionRelays = await getRelaysFromNip07Extension() |
||||
extensionRelays.forEach(url => { |
||||
const normalized = normalizeUrl(url) |
||||
if (normalized && !discovered.has(normalized)) { |
||||
discovered.set(normalized, { |
||||
url: normalized, |
||||
source: 'nip07', |
||||
selected: true |
||||
}) |
||||
} |
||||
}) |
||||
} catch (error) { |
||||
console.log('Could not fetch relays from NIP-07 extension:', error) |
||||
} |
||||
} |
||||
|
||||
// Note: Bunker relays are from the bunker connection URL itself
|
||||
// We could add logic here to extract relays from the bunker URL if needed
|
||||
|
||||
setDiscoveredRelays(Array.from(discovered.values())) |
||||
} catch (error) { |
||||
console.error('Error discovering relays:', error) |
||||
setErrorMsg(t('Failed to discover relays')) |
||||
} finally { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
|
||||
const handleToggleRelay = (url: string) => { |
||||
setDiscoveredRelays(prev => |
||||
prev.map(relay => |
||||
relay.url === url ? { ...relay, selected: !relay.selected } : relay |
||||
) |
||||
) |
||||
} |
||||
|
||||
const handleSelectAll = () => { |
||||
setDiscoveredRelays(prev => prev.map(relay => ({ ...relay, selected: true }))) |
||||
} |
||||
|
||||
const handleClearAll = () => { |
||||
setDiscoveredRelays(prev => prev.map(relay => ({ ...relay, selected: false }))) |
||||
} |
||||
|
||||
const handleAddSelected = async () => { |
||||
const selectedRelays = discoveredRelays.filter(r => r.selected) |
||||
if (selectedRelays.length === 0) return |
||||
|
||||
setIsAdding(true) |
||||
setErrorMsg('') |
||||
setSuccessMsg('') |
||||
|
||||
try { |
||||
const mailboxRelays: TMailboxRelay[] = selectedRelays.map(relay => ({ |
||||
url: relay.url, |
||||
scope: 'both' as const |
||||
})) |
||||
|
||||
onAdd(mailboxRelays) |
||||
setSuccessMsg(t('Added {{count}} relay(s)', { count: selectedRelays.length })) |
||||
setTimeout(() => setSuccessMsg(''), 3000) |
||||
|
||||
// Clear discovered relays after adding
|
||||
setDiscoveredRelays([]) |
||||
} catch (error) { |
||||
console.error('Failed to add relays:', error) |
||||
setErrorMsg(t('Failed to add relays')) |
||||
} finally { |
||||
setIsAdding(false) |
||||
} |
||||
} |
||||
|
||||
const getSourceLabel = (source: DiscoveredRelay['source']) => { |
||||
switch (source) { |
||||
case 'nip05': |
||||
return t('from NIP-05') |
||||
case 'nip07': |
||||
return t('from Extension') |
||||
case 'bunker': |
||||
return t('from Bunker') |
||||
} |
||||
} |
||||
|
||||
if (!profile || !account) { |
||||
return null |
||||
} |
||||
|
||||
if (isLoading) { |
||||
return ( |
||||
<div className="space-y-2"> |
||||
<div className="text-muted-foreground font-semibold select-none">{t('Discovered Relays')}</div> |
||||
<div className="flex items-center justify-center py-8 text-muted-foreground"> |
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> |
||||
{t('Discovering relays...')} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (discoveredRelays.length === 0) { |
||||
return null |
||||
} |
||||
|
||||
const selectedCount = discoveredRelays.filter(r => r.selected).length |
||||
|
||||
return ( |
||||
<div className="space-y-2"> |
||||
<div className="flex items-center justify-between"> |
||||
<div className="text-muted-foreground font-semibold select-none">{t('Discovered Relays')}</div> |
||||
<Button variant="ghost" size="sm" onClick={discoverRelays}> |
||||
{t('Refresh')} |
||||
</Button> |
||||
</div> |
||||
|
||||
<div className="text-sm text-muted-foreground mb-2"> |
||||
{t('These relays were found from your NIP-05 identifier and signer. You can add them to your relay list.')} |
||||
</div> |
||||
|
||||
<div className="border rounded-lg p-3 space-y-2 max-h-96 overflow-y-auto"> |
||||
{discoveredRelays.map((relay) => ( |
||||
<div key={relay.url} className="flex items-center gap-2 p-2 hover:bg-accent rounded"> |
||||
<Checkbox |
||||
id={`discovered-${relay.url}`} |
||||
checked={relay.selected} |
||||
onCheckedChange={() => handleToggleRelay(relay.url)} |
||||
/> |
||||
<label |
||||
htmlFor={`discovered-${relay.url}`} |
||||
className="flex items-center gap-2 flex-1 cursor-pointer" |
||||
> |
||||
<RelayIcon url={relay.url} className="w-4 h-4 shrink-0" /> |
||||
<div className="flex-1 min-w-0"> |
||||
<div className="truncate text-sm font-medium">{relay.url}</div> |
||||
<div className="text-xs text-muted-foreground">{getSourceLabel(relay.source)}</div> |
||||
</div> |
||||
</label> |
||||
</div> |
||||
))} |
||||
</div> |
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-2"> |
||||
<div className="flex gap-2"> |
||||
<Button variant="ghost" size="sm" onClick={handleSelectAll}> |
||||
{t('Select All')} |
||||
</Button> |
||||
<Button variant="ghost" size="sm" onClick={handleClearAll}> |
||||
{t('Clear All')} |
||||
</Button> |
||||
</div> |
||||
<Button |
||||
onClick={handleAddSelected} |
||||
disabled={selectedCount === 0 || isAdding} |
||||
> |
||||
{isAdding ? ( |
||||
<> |
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> |
||||
{t('Adding...')} |
||||
</> |
||||
) : ( |
||||
t('Add {{count}} Selected', { count: selectedCount }) |
||||
)} |
||||
</Button> |
||||
</div> |
||||
|
||||
{successMsg && ( |
||||
<div className="text-green-600 dark:text-green-400 text-sm flex items-center gap-1"> |
||||
<Check className="h-3 w-3" /> |
||||
{successMsg} |
||||
</div> |
||||
)} |
||||
{errorMsg && ( |
||||
<div className="text-destructive text-sm flex items-center gap-1"> |
||||
<AlertCircle className="h-3 w-3" /> |
||||
{errorMsg} |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,412 @@
@@ -0,0 +1,412 @@
|
||||
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 } from '@/lib/url' |
||||
import { TRelaySet } from '@/types' |
||||
|
||||
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 { |
||||
/** |
||||
* 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, selectableRelays) |
||||
|
||||
// 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>() |
||||
|
||||
// Always include user's write relays (or fallback to fast write relays)
|
||||
const userRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS |
||||
userRelays.forEach(url => selectableRelays.add(normalizeUrl(url) || url)) |
||||
|
||||
// Always include favorite relays
|
||||
favoriteRelays.forEach(url => selectableRelays.add(normalizeUrl(url) || url)) |
||||
|
||||
// Always include relays from relay sets
|
||||
relaySets.forEach(set => { |
||||
set.relayUrls.forEach(url => selectableRelays.add(normalizeUrl(url) || url)) |
||||
}) |
||||
|
||||
// Add contextual relays for replies and public messages
|
||||
if (parentEvent || isPublicMessage) { |
||||
const contextualRelays = await this.getContextualRelays(context) |
||||
contextualRelays.forEach(url => selectableRelays.add(normalizeUrl(url) || url)) |
||||
} |
||||
|
||||
// If called with specific relay URLs (e.g., from openFrom), include those
|
||||
if (openFrom && openFrom.length > 0) { |
||||
openFrom.forEach(url => selectableRelays.add(normalizeUrl(url) || url)) |
||||
} |
||||
|
||||
// Filter out blocked relays
|
||||
return this.filterBlockedRelays(Array.from(selectableRelays).filter(Boolean), 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
|
||||
if (parentEvent) { |
||||
const authorRelayList = await client.fetchRelayList(parentEvent.pubkey) |
||||
if (authorRelayList?.read) { |
||||
authorRelayList.read.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 public messages, also get mentioned users' read relays
|
||||
if (isPublicMessage && content && userPubkey) { |
||||
const mentions = await this.extractMentions(content, parentEvent) |
||||
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) |
||||
return relayList?.read || [] |
||||
} catch (error) { |
||||
console.warn(`Failed to fetch relay list for ${pubkey}:`, error) |
||||
return [] |
||||
} |
||||
}) |
||||
) |
||||
mentionRelayLists.flat().forEach(url => contextualRelays.add(url)) |
||||
} |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.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, |
||||
_selectableRelays: string[] |
||||
): Promise<string[]> { |
||||
const { |
||||
userWriteRelays, |
||||
parentEvent, |
||||
isPublicMessage, |
||||
openFrom |
||||
} = 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) |
||||
} |
||||
// 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)) { |
||||
selectedRelays = await this.getRegularReplyRelays(context) |
||||
} |
||||
// 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) |
||||
} |
||||
|
||||
// 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 => relays.add(normalizeUrl(url) || 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) |
||||
return relayList?.read || [] |
||||
} catch (error) { |
||||
console.warn(`Failed to fetch relay list for ${pubkey}:`, error) |
||||
return [] |
||||
} |
||||
}) |
||||
) |
||||
receiverRelayLists.flat().forEach(url => relays.add(normalizeUrl(url) || url)) |
||||
} |
||||
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { |
||||
// For public message replies, get original sender's read relays
|
||||
try { |
||||
const senderRelayList = await client.fetchRelayList(parentEvent.pubkey) |
||||
if (senderRelayList?.read) { |
||||
senderRelayList.read.forEach(url => relays.add(normalizeUrl(url) || url)) |
||||
} |
||||
} catch (error) { |
||||
console.warn(`Failed to fetch relay list for ${parentEvent.pubkey}:`, error) |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.error('Failed to get public message relays:', error) |
||||
} |
||||
|
||||
return Array.from(relays) |
||||
} |
||||
|
||||
/** |
||||
* Get relays for regular replies: user's write relays + mention relays |
||||
*/ |
||||
private async getRegularReplyRelays(context: RelaySelectionContext): Promise<string[]> { |
||||
const { userWriteRelays, parentEvent, content, userPubkey } = context |
||||
const relays = new Set<string>() |
||||
|
||||
try { |
||||
// Add user's write relays - fallback to fast write relays if no user relays
|
||||
const userRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS |
||||
userRelays.forEach(url => relays.add(normalizeUrl(url) || url)) |
||||
|
||||
// Add mentioned users' write relays
|
||||
if (content && userPubkey) { |
||||
const mentions = await this.extractMentions(content, parentEvent) |
||||
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) |
||||
return relayList?.write || [] |
||||
} catch (error) { |
||||
console.warn(`Failed to fetch relay list for ${pubkey}:`, error) |
||||
return [] |
||||
} |
||||
}) |
||||
) |
||||
mentionRelayLists.flat().forEach(url => relays.add(normalizeUrl(url) || url)) |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.error('Failed to get regular reply relays:', error) |
||||
} |
||||
|
||||
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) { |
||||
console.error('Failed to decode nostr address:', error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// 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 |
||||
} |
||||
|
||||
const normalizedBlocked = blockedRelays.map(url => normalizeUrl(url) || url) |
||||
return relays.filter(relay => { |
||||
const normalizedRelay = normalizeUrl(relay) || relay |
||||
return !normalizedBlocked.includes(normalizedRelay) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
const relaySelectionService = new RelaySelectionService() |
||||
export default relaySelectionService |
||||
Loading…
Reference in new issue