17 changed files with 1174 additions and 304 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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