Browse Source

relay selection rewrite

imwald
Silberengel 5 months ago
parent
commit
8a5a3fc766
  1. 94
      src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx
  2. 53
      src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx
  3. 24
      src/components/FavoriteRelaysSetting/BlockedRelayList.tsx
  4. 4
      src/components/FavoriteRelaysSetting/index.tsx
  5. 242
      src/components/MailboxSetting/DiscoveredRelays.tsx
  6. 12
      src/components/MailboxSetting/index.tsx
  7. 9
      src/components/PostEditor/PostContent.tsx
  8. 357
      src/components/PostEditor/PostRelaySelector.tsx
  9. 53
      src/components/SaveRelayDropdownMenu/index.tsx
  10. 1
      src/constants.ts
  11. 13
      src/lib/draft-event.ts
  12. 24
      src/lib/nip05.ts
  13. 110
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  14. 50
      src/providers/FavoriteRelaysProvider.tsx
  15. 19
      src/providers/NostrProvider/index.tsx
  16. 412
      src/services/relay-selection.service.ts
  17. 1
      src/types/index.d.ts

94
src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx

@ -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>
)
}

53
src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx

@ -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>
)
}

24
src/components/FavoriteRelaysSetting/BlockedRelayList.tsx

@ -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>
)
}

4
src/components/FavoriteRelaysSetting/index.tsx

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import AddBlockedRelay from './AddBlockedRelay'
import AddNewRelay from './AddNewRelay'
import AddNewRelaySet from './AddNewRelaySet'
import BlockedRelayList from './BlockedRelayList'
import FavoriteRelayList from './FavoriteRelayList'
import { RelaySetsSettingComponentProvider } from './provider'
import RelaySetList from './RelaySetList'
@ -12,6 +14,8 @@ export default function FavoriteRelaysSetting() { @@ -12,6 +14,8 @@ export default function FavoriteRelaysSetting() {
<AddNewRelaySet />
<FavoriteRelayList />
<AddNewRelay />
<BlockedRelayList />
<AddBlockedRelay />
</div>
</RelaySetsSettingComponentProvider>
)

242
src/components/MailboxSetting/DiscoveredRelays.tsx

@ -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>
)
}

12
src/components/MailboxSetting/index.tsx

@ -25,6 +25,7 @@ import MailboxRelay from './MailboxRelay' @@ -25,6 +25,7 @@ import MailboxRelay from './MailboxRelay'
import NewMailboxRelayInput from './NewMailboxRelayInput'
import RelayCountWarning from './RelayCountWarning'
import SaveButton from './SaveButton'
import DiscoveredRelays from './DiscoveredRelays'
export default function MailboxSetting() {
const { t } = useTranslation()
@ -107,6 +108,16 @@ export default function MailboxSetting() { @@ -107,6 +108,16 @@ export default function MailboxSetting() {
return null
}
const handleAddDiscoveredRelays = (newRelays: TMailboxRelay[]) => {
const relaysToAdd = newRelays.filter(
newRelay => !relays.some(r => r.url === newRelay.url)
)
if (relaysToAdd.length > 0) {
setRelays([...relays, ...relaysToAdd])
setHasChange(true)
}
}
return (
<div className="space-y-4">
<div className="text-xs text-muted-foreground space-y-1">
@ -114,6 +125,7 @@ export default function MailboxSetting() { @@ -114,6 +125,7 @@ export default function MailboxSetting() {
<div>{t('write relays description')}</div>
<div>{t('read & write relays notice')}</div>
</div>
<DiscoveredRelays onAdd={handleAddDiscoveredRelays} />
<RelayCountWarning relays={relays} />
<SaveButton mailboxRelays={relays} hasChange={hasChange} setHasChange={setHasChange} />
<DndContext

9
src/components/PostEditor/PostContent.tsx

@ -62,7 +62,6 @@ export default function PostContent({ @@ -62,7 +62,6 @@ export default function PostContent({
const [publicMessageRecipients, setPublicMessageRecipients] = useState<string[]>([])
const [isProtectedEvent, setIsProtectedEvent] = useState(false)
const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>([])
const [userWriteRelays, setUserWriteRelays] = useState<string[]>([])
const [isHighlight, setIsHighlight] = useState(false)
const [highlightData, setHighlightData] = useState<HighlightData>({
sourceType: 'nostr',
@ -247,7 +246,7 @@ export default function PostContent({ @@ -247,7 +246,7 @@ export default function PostContent({
// console.log('Publishing draft event:', draftEvent)
const newEvent = await publish(draftEvent, {
specifiedRelayUrls: isProtectedEvent ? additionalRelayUrls.filter(url => !userWriteRelays.includes(url)) : undefined,
specifiedRelayUrls: additionalRelayUrls.length > 0 ? additionalRelayUrls : undefined,
additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls,
minPow
})
@ -256,9 +255,7 @@ export default function PostContent({ @@ -256,9 +255,7 @@ export default function PostContent({
// Check if we need to refresh the current relay view
if (feedInfo.feedType === 'relay' && feedInfo.id) {
const currentRelayUrl = normalizeUrl(feedInfo.id)
const publishedRelays = isProtectedEvent
? additionalRelayUrls.filter(url => !userWriteRelays.includes(url))
: additionalRelayUrls
const publishedRelays = additionalRelayUrls
// If we published to the current relay being viewed, trigger a refresh after a short delay
if (publishedRelays.some(url => normalizeUrl(url) === currentRelayUrl)) {
@ -492,10 +489,10 @@ export default function PostContent({ @@ -492,10 +489,10 @@ export default function PostContent({
<PostRelaySelector
setIsProtectedEvent={setIsProtectedEvent}
setAdditionalRelayUrls={setAdditionalRelayUrls}
setUserWriteRelays={setUserWriteRelays}
parentEvent={parentEvent}
openFrom={openFrom}
content={text}
isPublicMessage={isPublicMessage}
/>
)}
<div className="flex items-center justify-between">

357
src/components/PostEditor/PostRelaySelector.tsx

@ -1,13 +1,3 @@ @@ -1,13 +1,3 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ExtendedKind } from '@/constants'
import client from '@/services/client.service'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import { simplifyUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -15,308 +5,167 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -15,308 +5,167 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Check } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import { extractMentions } from './Mentions'
import relaySelectionService from '@/services/relay-selection.service'
export default function PostRelaySelector({
parentEvent: _parentEvent,
openFrom,
setIsProtectedEvent,
setAdditionalRelayUrls,
setUserWriteRelays,
content: postContent = ''
content: postContent = '',
isPublicMessage = false
}: {
parentEvent?: NostrEvent
openFrom?: string[]
setIsProtectedEvent: Dispatch<SetStateAction<boolean>>
setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>>
setUserWriteRelays?: Dispatch<SetStateAction<string[]>>
content?: string
isPublicMessage?: boolean
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { relayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const { pubkey } = useNostr()
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const { pubkey, relayList } = useNostr()
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [mentionRelays, setMentionRelays] = useState<string[]>([])
// Fetch mention relays for regular replies (not discussion replies)
const isRegularReply = useMemo(() => {
if (!_parentEvent) return false
// Kind 1 or Kind 1111 that is not a reply to Kind 11 (discussion)
return (_parentEvent.kind === 1 || _parentEvent.kind === ExtendedKind.COMMENT) &&
_parentEvent.kind !== ExtendedKind.DISCUSSION
}, [_parentEvent])
// Get all selectable relays (write relays + favorite relays + relays from relay sets + mention relays)
const selectableRelays = useMemo(() => {
// Normalize all relay URLs before combining them
const normalizedRelays = [
...relayUrls.map(url => normalizeUrl(url) || url),
...favoriteRelays.map(url => normalizeUrl(url) || url),
...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)),
...mentionRelays.map(url => normalizeUrl(url) || url)
].filter(Boolean) // Remove any null/undefined values
return Array.from(new Set(normalizedRelays))
}, [relayUrls, favoriteRelays, relaySets, mentionRelays])
const description = useMemo(() => {
if (selectedRelayUrls.length === 0) {
return t('No relays selected')
}
if (selectedRelayUrls.length === 1) {
return simplifyUrl(selectedRelayUrls[0])
}
return t('{{count}} relays', { count: selectedRelayUrls.length })
}, [selectedRelayUrls])
const [selectableRelays, setSelectableRelays] = useState<string[]>([])
const [description, setDescription] = useState('')
const [isLoading, setIsLoading] = useState(true)
// Fetch mention relays when content changes for regular replies
// Use centralized relay selection service
useEffect(() => {
if (!isRegularReply) {
setMentionRelays([])
return
}
const fetchMentionRelays = async () => {
const updateRelaySelection = async () => {
setIsLoading(true)
try {
console.log('PostRelaySelector: extractMentions called with:', { postContent, parentEvent: _parentEvent?.id })
const { pubkeys, relatedPubkeys } = await extractMentions(postContent, _parentEvent)
console.log('PostRelaySelector: extractMentions returned:', { pubkeys, relatedPubkeys })
// Combine all mentioned pubkeys and filter out current user's pubkey
const allMentionPubkeys = [...pubkeys, ...relatedPubkeys]
const filteredMentionPubkeys = allMentionPubkeys.filter(p => p !== pubkey)
console.log('PostRelaySelector: filtered mention pubkeys:', filteredMentionPubkeys)
if (filteredMentionPubkeys.length === 0) {
setMentionRelays([])
return
}
// Fetch relay lists for all mentioned users (including parent event author)
console.log('PostRelaySelector: Fetching relays for pubkeys:', filteredMentionPubkeys)
const relayListPromises = filteredMentionPubkeys.map(async (pubkey) => {
try {
const relayList = await client.fetchRelayList(pubkey)
console.log(`PostRelaySelector: Fetched relays for ${pubkey}:`, relayList?.write || [])
return relayList?.write || []
} catch (error) {
console.warn(`Failed to fetch relay list for ${pubkey}:`, error)
return []
}
const result = await relaySelectionService.selectRelays({
userWriteRelays: relayList?.write || relayUrls,
userReadRelays: relayList?.read || [],
favoriteRelays,
blockedRelays,
relaySets,
parentEvent: _parentEvent,
isPublicMessage,
content: postContent,
userPubkey: pubkey || undefined,
openFrom
})
const relayLists = await Promise.all(relayListPromises)
const allMentionRelays = relayLists.flat()
const uniqueMentionRelays = Array.from(new Set(allMentionRelays))
setSelectableRelays(result.selectableRelays)
setSelectedRelayUrls(result.selectedRelays)
setDescription(result.description)
console.log('PostRelaySelector: Setting mention relays:', uniqueMentionRelays)
setMentionRelays(uniqueMentionRelays)
console.log('PostRelaySelector: Updated relay selection:', result)
} catch (error) {
console.error('Error fetching mention relays:', error)
setMentionRelays([])
console.error('Failed to update relay selection:', error)
setSelectableRelays([])
setSelectedRelayUrls([])
setDescription('No relays selected')
} finally {
setIsLoading(false)
}
}
// Debounce the fetch
const timeoutId = setTimeout(fetchMentionRelays, 300)
return () => clearTimeout(timeoutId)
}, [postContent, isRegularReply, _parentEvent])
// Initialize selected relays based on context
useEffect(() => {
if (openFrom && openFrom.length) {
// If called with specific relay URLs (e.g., from a discussion thread)
setSelectedRelayUrls(Array.from(new Set(openFrom)))
return
}
// Check if we're replying to a discussion or comment that requires specific relay routing
if (_parentEvent && (_parentEvent.kind === ExtendedKind.DISCUSSION || _parentEvent.kind === ExtendedKind.COMMENT)) {
let relayHint: string | undefined
if (_parentEvent.kind === ExtendedKind.COMMENT) {
// For kind 1111 (COMMENT): look for 'E' tag which points to the root event
const ETag = _parentEvent.tags.find(tag => tag[0] === 'E')
if (ETag && ETag[2]) {
relayHint = ETag[2] // Relay hint is the 3rd element
}
// If no 'E' tag, check lowercase 'e' tag for parent event
if (!relayHint) {
const eTag = _parentEvent.tags.find(tag => tag[0] === 'e')
if (eTag && eTag[2]) {
relayHint = 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) {
relayHint = eventHints[0]
}
}
// If we found a valid relay hint, use it instead of write relays
if (relayHint && isWebsocketUrl(relayHint)) {
const normalizedRelayHint = normalizeUrl(relayHint)
if (normalizedRelayHint) {
setSelectedRelayUrls([normalizedRelayHint])
return
}
}
}
// Default to write relays + mention relays for regular replies, or just write relays for other cases
if (isRegularReply) {
// For regular replies, include write relays and mention relays
// Normalize URLs before combining to avoid duplicates with/without trailing slashes
const normalizedWriteRelays = relayUrls.map(url => normalizeUrl(url) || url)
const normalizedMentionRelays = mentionRelays.map(url => normalizeUrl(url) || url)
const defaultRelays = Array.from(new Set([...normalizedWriteRelays, ...normalizedMentionRelays]))
console.log('PostRelaySelector: Setting default relays for regular reply:', {
relayUrls,
mentionRelays,
defaultRelays,
isRegularReply
})
setSelectedRelayUrls(defaultRelays)
} else {
// For other cases, just use write relays
console.log('PostRelaySelector: Setting default relays for non-regular reply:', relayUrls)
setSelectedRelayUrls(relayUrls)
}
}, [openFrom, _parentEvent, relayUrls, isRegularReply, mentionRelays])
updateRelaySelection()
}, [openFrom, _parentEvent, relayUrls, favoriteRelays, blockedRelays, relaySets, isPublicMessage, postContent, pubkey, relayList])
// Update parent component with selected relays
useEffect(() => {
const isProtectedEvent = selectedRelayUrls.length > 0 && !selectedRelayUrls.some(url => relayUrls.includes(url))
setIsProtectedEvent(isProtectedEvent)
setAdditionalRelayUrls(selectedRelayUrls)
// Expose user's write relays to parent component
setUserWriteRelays?.(relayUrls)
}, [selectedRelayUrls, relayUrls, setIsProtectedEvent, setAdditionalRelayUrls, setUserWriteRelays])
}, [selectedRelayUrls, relayUrls, setIsProtectedEvent, setAdditionalRelayUrls])
const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => {
if (checked) {
setSelectedRelayUrls(prev => [...prev, url])
} else {
setSelectedRelayUrls(prev => prev.filter(selectedUrl => selectedUrl !== url))
setSelectedRelayUrls(prev => prev.filter(u => u !== url))
}
}, [])
const content = useMemo(() => {
if (selectableRelays.length === 0) {
return (
<div className="px-4 py-3 text-sm text-muted-foreground text-center">
{t('No relays available')}
</div>
)
}
const handleSelectAll = useCallback(() => {
setSelectedRelayUrls([...selectableRelays])
}, [selectableRelays])
return (
<div className="space-y-1 max-h-64 overflow-y-auto">
{selectableRelays.map((url) => (
<MenuItem
key={url}
checked={selectedRelayUrls.includes(url)}
onCheckedChange={(checked) => handleRelayCheckedChange(checked, url)}
>
<div className="flex items-center gap-2">
<RelayIcon url={url} />
<div className="truncate">{simplifyUrl(url)}</div>
</div>
</MenuItem>
))}
</div>
)
}, [selectedRelayUrls, selectableRelays])
const handleClearAll = useCallback(() => {
setSelectedRelayUrls([])
}, [])
if (isSmallScreen) {
return (
<>
<div className="flex items-center gap-2">
{t('Post to')}
<Button
variant="outline"
className="px-2 flex-1 max-w-fit justify-start"
onClick={() => setIsDrawerOpen(true)}
const content = (
<div className="space-y-2">
{selectableRelays.length > 0 && (
<div className="flex gap-2">
<button
onClick={handleSelectAll}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t('Select All')}
</button>
<button
onClick={handleClearAll}
className="text-xs text-muted-foreground hover:text-foreground"
>
<div className="truncate">{description}</div>
</Button>
{t('Clear All')}
</button>
</div>
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent className="max-h-[80vh]" hideOverlay>
<div
className="overflow-y-auto overscroll-contain py-2"
style={{ touchAction: 'pan-y' }}
>
{content}
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu>
<div className="flex items-center gap-2">
{t('Post to')}
<DropdownMenuTrigger asChild>
<Button variant="outline" className="px-2 flex-1 max-w-fit justify-start">
<div className="truncate">{description}</div>
</Button>
</DropdownMenuTrigger>
)}
<div className="max-h-48 overflow-y-auto space-y-1">
{isLoading ? (
<div className="text-sm text-muted-foreground p-2">{t('Loading relays...')}</div>
) : selectableRelays.length === 0 ? (
<div className="text-sm text-muted-foreground p-2">{t('No relays available')}</div>
) : (
selectableRelays.map((url) => {
const isChecked = selectedRelayUrls.includes(url)
return (
<div
key={url}
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer"
onClick={() => handleRelayCheckedChange(!isChecked, url)}
>
<div className="flex items-center justify-center w-4 h-4 border border-border rounded">
{isChecked && <Check className="w-3 h-3" />}
</div>
<RelayIcon url={url} className="w-4 h-4" />
<span className="text-sm flex-1 truncate">{simplifyUrl(url)}</span>
</div>
)
})
)}
</div>
<DropdownMenuContent align="start" className="max-w-96 max-h-[50vh]" showScrollButtons>
{content}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
function MenuItem({
children,
checked,
onCheckedChange
}: {
children: React.ReactNode
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<div
onClick={() => onCheckedChange(!checked)}
className="flex items-center gap-2 px-4 py-3 clickable"
>
<div className="flex items-center justify-center size-4 shrink-0">
{checked && <Check className="size-4" />}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t('Post to')}</span>
<span className="text-sm text-muted-foreground">{description}</span>
</div>
{/* Drawer implementation would go here */}
<div className="border border-border rounded p-2">
{content}
</div>
{children}
</div>
)
}
return (
<div
onClick={() => onCheckedChange(!checked)}
className="flex items-center gap-2 px-2 py-2 hover:bg-muted cursor-pointer rounded-sm"
>
<div className="flex items-center justify-center size-4 shrink-0">
{checked && <Check className="size-4" />}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t('Post to')}</span>
<span className="text-sm text-muted-foreground">{description}</span>
</div>
<div className="border border-border rounded p-2">
{content}
</div>
{children}
</div>
)
}
}

53
src/components/SaveRelayDropdownMenu/index.tsx

@ -20,7 +20,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -20,7 +20,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TRelaySet } from '@/types'
import { Check, FolderPlus, Plus, Star } from 'lucide-react'
import { Ban, Check, FolderPlus, Loader2, Plus, Star } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DrawerMenuItem from '../DrawerMenuItem'
@ -78,6 +78,8 @@ export default function SaveRelayDropdownMenu({ @@ -78,6 +78,8 @@ export default function SaveRelayDropdownMenu({
))}
<Separator />
<SaveToNewSet urls={normalizedUrls} />
<Separator />
<BlockRelayItem urls={normalizedUrls} />
</div>
</DrawerContent>
</Drawer>
@ -100,6 +102,8 @@ export default function SaveRelayDropdownMenu({ @@ -100,6 +102,8 @@ export default function SaveRelayDropdownMenu({
))}
<DropdownMenuSeparator />
<SaveToNewSet urls={normalizedUrls} />
<DropdownMenuSeparator />
<BlockRelayItem urls={normalizedUrls} />
</DropdownMenuContent>
</DropdownMenu>
)
@ -229,3 +233,50 @@ function SaveToNewSet({ urls }: { urls: string[] }) { @@ -229,3 +233,50 @@ function SaveToNewSet({ urls }: { urls: string[] }) {
</DropdownMenuItem>
)
}
function BlockRelayItem({ urls }: { urls: string[] }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { blockedRelays, addBlockedRelays, deleteBlockedRelays } = useFavoriteRelays()
const [isLoading, setIsLoading] = useState(false)
const blocked = useMemo(
() => urls.every((url) => blockedRelays.includes(url)),
[blockedRelays, urls]
)
const handleClick = async () => {
if (isLoading) return
setIsLoading(true)
try {
if (blocked) {
await deleteBlockedRelays(urls)
} else {
await addBlockedRelays(urls)
}
} catch (error) {
console.error('Failed to toggle blocked relay:', error)
} finally {
setIsLoading(false)
}
}
if (isSmallScreen) {
return (
<DrawerMenuItem
onClick={isLoading ? undefined : handleClick}
className={isLoading ? 'opacity-50 cursor-not-allowed' : ''}
>
{isLoading ? <Loader2 className="animate-spin" /> : <Ban />}
{isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')}
</DrawerMenuItem>
)
}
return (
<DropdownMenuItem onClick={handleClick} disabled={isLoading}>
{isLoading ? <Loader2 className="animate-spin" /> : <Ban />}
{isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')}
</DropdownMenuItem>
)
}

1
src/constants.ts

@ -124,6 +124,7 @@ export const ExtendedKind = { @@ -124,6 +124,7 @@ export const ExtendedKind = {
PUBLIC_MESSAGE: 24,
DISCUSSION: 11,
FAVORITE_RELAYS: 10012,
BLOCKED_RELAYS: 10006,
BLOSSOM_SERVER_LIST: 10063,
RELAY_REVIEW: 31987,
GROUP_METADATA: 39000,

13
src/lib/draft-event.ts

@ -438,6 +438,19 @@ export function createFavoriteRelaysDraftEvent( @@ -438,6 +438,19 @@ export function createFavoriteRelaysDraftEvent(
}
}
export function createBlockedRelaysDraftEvent(blockedRelays: string[]): TDraftEvent {
const tags: string[][] = []
blockedRelays.forEach((url) => {
tags.push(buildRelayTag(url))
})
return {
kind: ExtendedKind.BLOCKED_RELAYS,
content: '',
tags,
created_at: dayjs().unix()
}
}
export function createSeenNotificationsAtDraftEvent(): TDraftEvent {
return {
kind: kinds.Application,

24
src/lib/nip05.ts

@ -5,6 +5,7 @@ type TVerifyNip05Result = { @@ -5,6 +5,7 @@ type TVerifyNip05Result = {
isVerified: boolean
nip05Name: string
nip05Domain: string
relays?: string[]
}
const verifyNip05ResultCache = new LRUCache<string, TVerifyNip05Result>({
@ -17,14 +18,16 @@ const verifyNip05ResultCache = new LRUCache<string, TVerifyNip05Result>({ @@ -17,14 +18,16 @@ const verifyNip05ResultCache = new LRUCache<string, TVerifyNip05Result>({
async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
const [nip05Name, nip05Domain] = nip05?.split('@') || [undefined, undefined]
const result = { isVerified: false, nip05Name, nip05Domain }
const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain }
if (!nip05Name || !nip05Domain || !pubkey) return result
try {
const res = await fetch(getWellKnownNip05Url(nip05Domain, nip05Name))
const json = await res.json()
if (json.names?.[nip05Name] === pubkey) {
return { ...result, isVerified: true }
// Also extract relays if available (NIP-05 spec allows a relays object)
const relays = json.relays?.[pubkey]
return { ...result, isVerified: true, relays: Array.isArray(relays) ? relays : undefined }
}
} catch {
// ignore
@ -69,3 +72,20 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> @@ -69,3 +72,20 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]>
return []
}
}
/**
* Attempt to get relays from NIP-07 extension
* Some extensions support a getRelays() method
*/
export async function getRelaysFromNip07Extension(): Promise<string[]> {
try {
if (window.nostr && typeof window.nostr.getRelays === 'function') {
const relaysObj = await window.nostr.getRelays()
// getRelays() returns an object like { "wss://relay.url": {read: true, write: true} }
return Object.keys(relaysObj || {})
}
} catch (error) {
console.log('NIP-07 extension does not support getRelays():', error)
}
return []
}

110
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -10,14 +10,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -10,14 +10,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Checkbox } from '@/components/ui/checkbox'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Hash, X, Users, Code, Coins, Newspaper, BookOpen, Scroll, Cpu, Trophy, Film, Heart, TrendingUp, Utensils, MapPin, Home, PawPrint, Shirt, Image, Zap, Settings, Book, Network, Car, Eye, Edit3 } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { TDraftEvent, TRelaySet } from '@/types'
import { NostrEvent } from 'nostr-tools'
import { prefixNostrAddresses } from '@/lib/nostr-address'
import { showPublishingError } from '@/lib/publishing-feedback'
import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { simplifyUrl } from '@/lib/url'
import relaySelectionService from '@/services/relay-selection.service'
import dayjs from 'dayjs'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics'
import DiscussionContent from '@/components/Note/DiscussionContent'
@ -82,17 +84,20 @@ export default function CreateThreadDialog({ @@ -82,17 +84,20 @@ export default function CreateThreadDialog({
onThreadCreated
}: CreateThreadDialogProps) {
const { t } = useTranslation()
const { pubkey, publish } = useNostr()
const { pubkey, publish, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [selectedTopic] = useState(initialTopic)
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<{ title?: string; content?: string; relay?: string; author?: string; subject?: string }>({})
const [isNsfw, setIsNsfw] = useState(false)
const [addClientTag, setAddClientTag] = useState(true)
const [minPow, setMinPow] = useState(0)
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
const [isLoadingRelays, setIsLoadingRelays] = useState(true)
// Readings options state
const [isReadingGroup, setIsReadingGroup] = useState(false)
@ -100,36 +105,62 @@ export default function CreateThreadDialog({ @@ -100,36 +105,62 @@ export default function CreateThreadDialog({
const [subject, setSubject] = useState('')
const [showReadingsPanel, setShowReadingsPanel] = useState(false)
// Get all selectable relays (includes relays from selected relay set if applicable)
const selectableRelays = useMemo(() => {
const relaySet = initialRelay ? relaySets.find(set => set.id === initialRelay) : null
if (relaySet) {
// Include relays from the selected set along with available relays
return Array.from(new Set([
...availableRelays.map(url => normalizeUrl(url) || url),
...relaySet.relayUrls.map(url => normalizeUrl(url) || url)
]))
// Initialize selected relays using the centralized relay selection service
useEffect(() => {
const initializeRelays = async () => {
setIsLoadingRelays(true)
try {
// Determine openFrom based on initialRelay
let openFrom: string[] | undefined = undefined
if (initialRelay) {
const relaySet = relaySets.find(set => set.id === initialRelay)
if (relaySet) {
openFrom = relaySet.relayUrls
} else {
openFrom = [initialRelay]
}
}
const result = await relaySelectionService.selectRelays({
userWriteRelays: relayList?.write || [],
userReadRelays: relayList?.read || [],
favoriteRelays,
blockedRelays,
relaySets,
openFrom,
userPubkey: pubkey || undefined
})
setSelectableRelays(result.selectableRelays)
setSelectedRelayUrls(result.selectedRelays)
} catch (error) {
console.error('Failed to initialize relays:', error)
// Fallback to availableRelays
setSelectableRelays(availableRelays)
setSelectedRelayUrls(availableRelays)
} finally {
setIsLoadingRelays(false)
}
}
return availableRelays
}, [availableRelays, relaySets, initialRelay])
// Initialize selected relays based on the originating view state
useEffect(() => {
if (initialRelay === null || initialRelay === undefined) {
// "All relays" selected - check all available relays
setSelectedRelayUrls(availableRelays)
initializeRelays()
}, [initialRelay, availableRelays, relaySets, favoriteRelays, blockedRelays, relayList, pubkey])
const handleRelayCheckedChange = (checked: boolean, url: string) => {
if (checked) {
setSelectedRelayUrls(prev => [...prev, url])
} else {
// Check if it's a relay set ID
const relaySet = relaySets.find(set => set.id === initialRelay)
if (relaySet) {
// It's a relay set - check all relays in that set
setSelectedRelayUrls(relaySet.relayUrls)
} else {
// It's a specific relay - check just that relay
setSelectedRelayUrls([initialRelay])
}
setSelectedRelayUrls(prev => prev.filter(u => u !== url))
}
}, [initialRelay, availableRelays, relaySets])
}
const handleSelectAll = () => {
setSelectedRelayUrls([...selectableRelays])
}
const handleClearAll = () => {
setSelectedRelayUrls([])
}
const validateForm = () => {
const newErrors: { title?: string; content?: string; relay?: string; author?: string; subject?: string } = {}
@ -512,7 +543,11 @@ export default function CreateThreadDialog({ @@ -512,7 +543,11 @@ export default function CreateThreadDialog({
<div className="space-y-2">
<Label>{t('Publish to Relays')}</Label>
<ScrollArea className={`max-h-64 rounded-md border p-4 ${errors.relay ? 'border-destructive' : ''}`}>
{selectableRelays.length === 0 ? (
{isLoadingRelays ? (
<div className="text-sm text-muted-foreground text-center py-4">
{t('Loading relays...')}
</div>
) : selectableRelays.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-4">
{t('No relays available. Please configure relays in settings.')}
</div>
@ -525,13 +560,8 @@ export default function CreateThreadDialog({ @@ -525,13 +560,8 @@ export default function CreateThreadDialog({
<Checkbox
id={`relay-${relay}`}
checked={isChecked}
onCheckedChange={(checked) => {
if (checked) {
setSelectedRelayUrls(prev => [...prev, relay])
} else {
setSelectedRelayUrls(prev => prev.filter(url => url !== relay))
}
}}
onCheckedChange={(checked) => handleRelayCheckedChange(!!checked, relay)}
disabled={isLoadingRelays}
/>
<label
htmlFor={`relay-${relay}`}
@ -560,7 +590,8 @@ export default function CreateThreadDialog({ @@ -560,7 +590,8 @@ export default function CreateThreadDialog({
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedRelayUrls(selectableRelays)}
onClick={handleSelectAll}
disabled={isLoadingRelays}
>
{t('Select All')}
</Button>
@ -568,7 +599,8 @@ export default function CreateThreadDialog({ @@ -568,7 +599,8 @@ export default function CreateThreadDialog({
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedRelayUrls([])}
onClick={handleClearAll}
disabled={isLoadingRelays}
>
{t('Clear All')}
</Button>

50
src/providers/FavoriteRelaysProvider.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { BIG_RELAY_URLS, DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { createFavoriteRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event'
import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event'
import { getReplaceableEventIdentifier } from '@/lib/event'
import { getRelaySetFromEvent } from '@/lib/event-metadata'
import { randomString } from '@/lib/random'
@ -17,6 +17,9 @@ type TFavoriteRelaysContext = { @@ -17,6 +17,9 @@ type TFavoriteRelaysContext = {
addFavoriteRelays: (relayUrls: string[]) => Promise<void>
deleteFavoriteRelays: (relayUrls: string[]) => Promise<void>
reorderFavoriteRelays: (reorderedRelays: string[]) => Promise<void>
blockedRelays: string[]
addBlockedRelays: (relayUrls: string[]) => Promise<void>
deleteBlockedRelays: (relayUrls: string[]) => Promise<void>
relaySets: TRelaySet[]
createRelaySet: (relaySetName: string, relayUrls?: string[]) => Promise<void>
addRelaySets: (newRelaySetEvents: Event[]) => Promise<void>
@ -36,8 +39,9 @@ export const useFavoriteRelays = () => { @@ -36,8 +39,9 @@ export const useFavoriteRelays = () => {
}
export function FavoriteRelaysProvider({ children }: { children: React.ReactNode }) {
const { favoriteRelaysEvent, updateFavoriteRelaysEvent, pubkey, relayList, publish } = useNostr()
const { favoriteRelaysEvent, blockedRelaysEvent, updateFavoriteRelaysEvent, updateBlockedRelaysEvent, pubkey, relayList, publish } = useNostr()
const [favoriteRelays, setFavoriteRelays] = useState<string[]>([])
const [blockedRelays, setBlockedRelays] = useState<string[]>([])
const [relaySetEvents, setRelaySetEvents] = useState<Event[]>([])
const [relaySets, setRelaySets] = useState<TRelaySet[]>([])
@ -140,6 +144,24 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -140,6 +144,24 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
init()
}, [favoriteRelaysEvent])
useEffect(() => {
if (!blockedRelaysEvent) {
setBlockedRelays([])
return
}
const relays: string[] = []
blockedRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) {
const normalizedUrl = normalizeUrl(tagValue)
if (normalizedUrl && !relays.includes(normalizedUrl)) {
relays.push(normalizedUrl)
}
}
})
setBlockedRelays(relays)
}, [blockedRelaysEvent])
useEffect(() => {
setRelaySets(
relaySetEvents.map((evt) => getRelaySetFromEvent(evt)).filter(Boolean) as TRelaySet[]
@ -237,6 +259,27 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -237,6 +259,27 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const addBlockedRelays = async (relayUrls: string[]) => {
const normalizedUrls = relayUrls
.map((relayUrl) => normalizeUrl(relayUrl))
.filter((url) => !!url && !blockedRelays.includes(url))
if (!normalizedUrls.length) return
const newBlockedRelays = [...blockedRelays, ...normalizedUrls]
setBlockedRelays(newBlockedRelays)
const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays)
const newBlockedRelaysEvent = await publish(draftEvent)
updateBlockedRelaysEvent(newBlockedRelaysEvent)
}
const deleteBlockedRelays = async (relayUrls: string[]) => {
const normalizedUrls = relayUrls.map((relayUrl) => normalizeUrl(relayUrl)).filter(Boolean)
const newBlockedRelays = blockedRelays.filter((relay) => !normalizedUrls.includes(relay))
setBlockedRelays(newBlockedRelays)
const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays)
const newBlockedRelaysEvent = await publish(draftEvent)
updateBlockedRelaysEvent(newBlockedRelaysEvent)
}
const reorderRelaySets = async (reorderedSets: TRelaySet[]) => {
setRelaySets(reorderedSets)
const draftEvent = createFavoriteRelaysDraftEvent(
@ -254,6 +297,9 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -254,6 +297,9 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
addFavoriteRelays,
deleteFavoriteRelays,
reorderFavoriteRelays,
blockedRelays,
addBlockedRelays,
deleteBlockedRelays,
relaySets,
createRelaySet,
addRelaySets,

19
src/providers/NostrProvider/index.tsx

@ -56,6 +56,7 @@ type TNostrContext = { @@ -56,6 +56,7 @@ type TNostrContext = {
bookmarkListEvent: Event | null
interestListEvent: Event | null
favoriteRelaysEvent: Event | null
blockedRelaysEvent: Event | null
userEmojiListEvent: Event | null
notificationsSeenAt: number
account: TAccountPointer | null
@ -88,6 +89,7 @@ type TNostrContext = { @@ -88,6 +89,7 @@ type TNostrContext = {
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
updateInterestListEvent: (interestListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
}
@ -159,6 +161,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -159,6 +161,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
const [interestListEvent, setInterestListEvent] = useState<Event | null>(null)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
const [blockedRelaysEvent, setBlockedRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
const [isInitialized, setIsInitialized] = useState(false)
@ -307,6 +310,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -307,6 +310,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList)
const interestListEvent = sortedEvents.find((e) => e.kind === 10015)
const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS)
const blockedRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.BLOCKED_RELAYS)
const blossomServerListEvent = sortedEvents.find(
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
)
@ -359,6 +363,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -359,6 +363,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFavoriteRelaysEvent(updatedFavoriteRelaysEvent)
}
}
if (blockedRelaysEvent) {
const updatedBlockedRelaysEvent = await indexedDb.putReplaceableEvent(blockedRelaysEvent)
if (updatedBlockedRelaysEvent.id === blockedRelaysEvent.id) {
setBlockedRelaysEvent(updatedBlockedRelaysEvent)
}
}
if (blossomServerListEvent) {
await client.updateBlossomServerListEventCache(blossomServerListEvent)
}
@ -847,6 +857,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -847,6 +857,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const updateBlockedRelaysEvent = async (blockedRelaysEvent: Event) => {
const newBlockedRelaysEvent = await indexedDb.putReplaceableEvent(blockedRelaysEvent)
if (newBlockedRelaysEvent.id !== blockedRelaysEvent.id) return
setBlockedRelaysEvent(newBlockedRelaysEvent)
}
const updateNotificationsSeenAt = async (skipPublish = false) => {
if (!account) return
@ -882,6 +899,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -882,6 +899,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
bookmarkListEvent,
interestListEvent,
favoriteRelaysEvent,
blockedRelaysEvent,
userEmojiListEvent,
notificationsSeenAt,
account,
@ -911,6 +929,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -911,6 +929,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateBookmarkListEvent,
updateInterestListEvent,
updateFavoriteRelaysEvent,
updateBlockedRelaysEvent,
updateNotificationsSeenAt
}}
>

412
src/services/relay-selection.service.ts

@ -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

1
src/types/index.d.ts vendored

@ -83,6 +83,7 @@ export type TNip07 = { @@ -83,6 +83,7 @@ export type TNip07 = {
encrypt?: (pubkey: string, plainText: string) => Promise<string>
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
}
getRelays?: () => Promise<{ [url: string]: { read: boolean; write: boolean } }>
}
export interface ISigner {

Loading…
Cancel
Save