You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

591 lines
24 KiB

import {
ExtendedKind,
isSocialKindBlockedKind,
MAX_PUBLISH_RELAYS,
READ_ONLY_RELAY_URLS,
SOCIAL_KIND_BLOCKED_RELAY_URLS
} from '@/constants'
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { simplifyUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider'
import { getRelayListFromEvent } from '@/lib/event-metadata'
import indexedDb from '@/services/indexed-db.service'
import { Check, ChevronDown, Server } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import relaySelectionService, { type RelaySourceType } from '@/services/relay-selection.service'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import logger from '@/lib/logger'
/** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */
const NO_MENTIONS: string[] = []
export default function PostRelaySelector({
parentEvent: _parentEvent,
openFrom,
setIsProtectedEvent,
setAdditionalRelayUrls,
content: postContent = '',
isPublicMessage = false,
mentions = NO_MENTIONS
}: {
parentEvent?: NostrEvent
openFrom?: string[]
setIsProtectedEvent: Dispatch<SetStateAction<boolean>>
setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>>
content?: string
isPublicMessage?: boolean
mentions?: string[]
}) {
const { t } = useTranslation()
/** Subtitle + trigger must match {@link selectedRelayUrls} (service description ignored: cache relays are merged in after). */
const describeRelaySelection = useCallback(
(urls: string[]) => {
const n = urls.length
if (n === 0) return t('No relays selected')
if (n === 1) return simplifyUrl(urls[0])
return t('{{count}} relays', { count: n })
},
[t]
)
const { isSmallScreen } = useScreenSize()
useCurrentRelays() // Keep this hook call for any side effects
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const { pubkey, relayList } = useNostr()
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([])
const [relayTypes, setRelayTypes] = useState<Record<string, RelaySourceType>>({})
const [description, setDescription] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [hasManualSelection, setHasManualSelection] = useState(false)
const [previousSelectableCount, setPreviousSelectableCount] = useState(0)
const [previousMentions, setPreviousMentions] = useState<string[]>([])
// Initialize previousMentions with the initial mentions value
useEffect(() => {
setPreviousMentions(mentions)
}, []) // Only run once on mount
// For discussion replies, content doesn't affect relay selection
// Check if this is a reply to a discussion by looking for "K" tag with "11"
const isDiscussionReply = useMemo(() => {
if (!_parentEvent) return false
// Direct reply to discussion
if (_parentEvent.kind === 11) return true
// Check if parent event has "K" tag containing "11" (discussion root kind)
const eventTags = _parentEvent.tags || []
const kindTag = eventTags.find(([tagName]) => tagName === 'K')
if (kindTag && kindTag[1] === '11') {
return true
}
return false
}, [_parentEvent])
/**
* Same merge order as {@link ClientService.publishEvent}: NIP-65 write list first, then relays checked here,
* then cap at {@link MAX_PUBLISH_RELAYS}. Drives the cap hint so users see reserved “prepended” slots.
*/
const publishCapPreview = useMemo(() => {
const applySocialOutboxFilter =
!isPublicMessage &&
(_parentEvent == null ||
isDiscussionReply ||
(_parentEvent != null && isSocialKindBlockedKind(_parentEvent.kind)))
const wsOut = (relayList?.write ?? [])
.map((u) => normalizeUrl(u) || u)
.filter((u): u is string => !!u)
const httpOut = (relayList?.httpWrite ?? [])
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter((u): u is string => !!u)
let outbox = dedupeNormalizeRelayUrlsOrdered([...httpOut, ...wsOut])
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
const socialBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
outbox = dedupeNormalizeRelayUrlsOrdered(
outbox.filter((url) => {
const n = normalizeAnyRelayUrl(url) || url
if (readOnlySet.has(n)) return false
if (applySocialOutboxFilter && socialBlockedSet.has(n)) return false
return true
})
)
const merged = dedupeNormalizeRelayUrlsOrdered([...outbox, ...selectedRelayUrls])
const capped = merged.slice(0, MAX_PUBLISH_RELAYS)
const outboxNormSet = new Set(outbox)
const outboxSlotsInPublish = capped.filter((u) => outboxNormSet.has(u)).length
const selectedNorm = selectedRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u)
const selectedContacted = selectedNorm.filter((u) => capped.includes(u)).length
const showCapHint =
merged.length > MAX_PUBLISH_RELAYS ||
selectedRelayUrls.length >= MAX_PUBLISH_RELAYS ||
selectedContacted < selectedRelayUrls.length
return {
outboxSlotsInPublish,
selectedContacted,
selectedTotal: selectedRelayUrls.length,
showCapHint
}
}, [
relayList?.write,
relayList?.httpWrite,
selectedRelayUrls,
isPublicMessage,
_parentEvent,
isDiscussionReply
])
/**
* Relay selection only cares about nostr:… mentions in the draft (see relay-selection.service).
* Depending on full `postContent` re-ran the heavy relay effect on every keystroke.
*/
const contentRelaySignature = useMemo(() => {
if (isDiscussionReply) return ''
if (isPublicMessage && mentions.length > 0) {
// PM recipients come from `mentions` when set; content is ignored by selection service
return ''
}
const matches = [...postContent.matchAll(NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX)].map((m) => m[0])
if (!matches.length) return ''
return [...new Set(matches)].sort().join('\n')
}, [postContent, isDiscussionReply, isPublicMessage, mentions])
// Memoize arrays to prevent unnecessary re-renders
const memoizedFavoriteRelays = useMemo(() => favoriteRelays, [favoriteRelays])
const memoizedBlockedRelays = useMemo(() => {
// Top-level compose or reply under a social thread: also block SOCIAL_KIND_BLOCKED_RELAY_URLS in the picker.
const isSocialPublish =
!isPublicMessage &&
(_parentEvent == null ||
isDiscussionReply ||
isSocialKindBlockedKind(_parentEvent.kind))
return isSocialPublish
? [...blockedRelays, ...SOCIAL_KIND_BLOCKED_RELAY_URLS]
: blockedRelays
}, [blockedRelays, isPublicMessage, _parentEvent, isDiscussionReply])
const memoizedRelaySets = useMemo(() => relaySets, [relaySets])
const memoizedOpenFrom = useMemo(() => openFrom, [openFrom])
// Use centralized relay selection service - only for non-content dependencies
useEffect(() => {
const updateRelaySelection = async () => {
setIsLoading(true)
try {
// Ensure cache relays (kind 10432) are included in userWriteRelays even if relayList hasn't been updated yet
// Get cache relays directly from IndexedDB (don't fetch new every time)
let userWriteRelays = relayList?.write || []
if (pubkey) {
try {
const cacheRelayListEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayListEvent) {
const cacheRelayList = getRelayListFromEvent(cacheRelayListEvent)
// Get all cache relays (they should all be local network URLs)
// Include both write and both-scoped relays (cache relays should be write-capable)
const cacheRelays = [
...cacheRelayList.write,
...cacheRelayList.originalRelays
.filter(relay => (relay.scope === 'both' || relay.scope === 'write') && isLocalNetworkUrl(relay.url))
.map(relay => relay.url)
].filter(url => {
// Filter out invalid/empty URLs
if (!url || typeof url !== 'string' || url.trim() === '' || url === 'ws://' || url === 'wss://') return false
return isLocalNetworkUrl(url)
})
const existingUrls = new Set(userWriteRelays.map(url => normalizeUrl(url) || url))
const newCacheRelays = cacheRelays
.map(url => normalizeUrl(url) || url)
.filter((url): url is string => !!url && !existingUrls.has(url))
if (newCacheRelays.length > 0) {
userWriteRelays = [...newCacheRelays, ...userWriteRelays]
}
}
} catch (error) {
logger.warn('Failed to get cache relays from IndexedDB', { error, pubkey })
}
}
const result = await relaySelectionService.selectRelays({
userWriteRelays,
userHttpWriteRelays: relayList?.httpWrite ?? [],
userReadRelays: relayList?.read || [],
favoriteRelays: memoizedFavoriteRelays,
blockedRelays: memoizedBlockedRelays,
relaySets: memoizedRelaySets,
parentEvent: _parentEvent,
isPublicMessage,
content: isDiscussionReply ? '' : postContent, // Don't use content for discussion replies
mentions: isPublicMessage ? mentions : undefined, // Pass mentions for PMs
userPubkey: pubkey || undefined,
openFrom: memoizedOpenFrom
})
const newSelectableCount = result.selectableRelays.length
const selectableRelaysChanged = newSelectableCount !== previousSelectableCount
setSelectableRelays(result.selectableRelays)
setRelayTypes(result.relayTypes ?? {})
setPreviousSelectableCount(newSelectableCount)
// Only update selected relays if:
// 1. User hasn't manually modified them, OR
// 2. Selectable relays changed
if (!hasManualSelection || selectableRelaysChanged) {
// Ensure cache relays are included by default (but user can uncheck them)
const cacheRelays = result.selectableRelays.filter(url => isLocalNetworkUrl(url))
const selectedWithCache = Array.from(new Set([...result.selectedRelays, ...cacheRelays]))
setSelectedRelayUrls(selectedWithCache)
setDescription(describeRelaySelection(selectedWithCache))
// Reset manual selection flag if relays changed
if (selectableRelaysChanged && hasManualSelection) {
setHasManualSelection(false)
}
}
} catch (error) {
logger.error('Failed to update relay selection', { error })
setSelectableRelays([])
if (!hasManualSelection) {
setSelectedRelayUrls([])
setDescription(t('No relays selected'))
}
} finally {
setIsLoading(false)
}
}
updateRelaySelection()
}, [
memoizedOpenFrom,
_parentEvent,
memoizedFavoriteRelays,
memoizedBlockedRelays,
memoizedRelaySets,
isPublicMessage,
pubkey,
relayList,
isDiscussionReply,
contentRelaySignature,
mentions,
describeRelaySelection,
t
])
// Separate effect for mention changes in non-discussion replies
useEffect(() => {
if (isDiscussionReply) return // Skip for discussion replies
const mentionsChanged = JSON.stringify(mentions) !== JSON.stringify(previousMentions)
if (mentionsChanged) {
setPreviousMentions(mentions)
// Update relay selection when mentions change
const updateRelaySelection = async () => {
setIsLoading(true)
try {
// Ensure cache relays (kind 10432) are included in userWriteRelays even if relayList hasn't been updated yet
// Get cache relays directly from IndexedDB (don't fetch new every time)
let userWriteRelays = relayList?.write || []
if (pubkey) {
try {
const cacheRelayListEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayListEvent) {
const cacheRelayList = getRelayListFromEvent(cacheRelayListEvent)
// Get all cache relays (they should all be local network URLs)
// Include both write and both-scoped relays (cache relays should be write-capable)
const cacheRelays = [
...cacheRelayList.write,
...cacheRelayList.originalRelays
.filter(relay => (relay.scope === 'both' || relay.scope === 'write') && isLocalNetworkUrl(relay.url))
.map(relay => relay.url)
].filter(url => isLocalNetworkUrl(url))
const existingUrls = new Set(userWriteRelays.map(url => normalizeUrl(url) || url))
const newCacheRelays = cacheRelays
.map(url => normalizeUrl(url) || url)
.filter((url): url is string => !!url && !existingUrls.has(url))
if (newCacheRelays.length > 0) {
userWriteRelays = [...newCacheRelays, ...userWriteRelays]
}
}
} catch (error) {
logger.warn('Failed to get cache relays from IndexedDB', { error, pubkey })
}
}
const result = await relaySelectionService.selectRelays({
userWriteRelays,
userHttpWriteRelays: relayList?.httpWrite ?? [],
userReadRelays: relayList?.read || [],
favoriteRelays: memoizedFavoriteRelays,
blockedRelays: memoizedBlockedRelays,
relaySets: memoizedRelaySets,
parentEvent: _parentEvent,
isPublicMessage,
content: isDiscussionReply ? '' : postContent, // Don't use content for discussion replies
mentions: isPublicMessage ? mentions : undefined, // Pass mentions for PMs
userPubkey: pubkey || undefined,
openFrom: memoizedOpenFrom
})
const newSelectableCount = result.selectableRelays.length
const selectableRelaysChanged = newSelectableCount !== previousSelectableCount
setSelectableRelays(result.selectableRelays)
setRelayTypes(result.relayTypes ?? {})
setPreviousSelectableCount(newSelectableCount)
// Only update selected relays if:
// 1. User hasn't manually modified them, OR
// 2. Selectable relays changed
if (!hasManualSelection || selectableRelaysChanged) {
// Ensure cache relays are included by default (but user can uncheck them)
const cacheRelays = result.selectableRelays.filter(url => isLocalNetworkUrl(url))
const selectedWithCache = Array.from(new Set([...result.selectedRelays, ...cacheRelays]))
setSelectedRelayUrls(selectedWithCache)
setDescription(describeRelaySelection(selectedWithCache))
// Reset manual selection flag if relays changed
if (selectableRelaysChanged && hasManualSelection) {
setHasManualSelection(false)
}
}
} catch (error) {
logger.error('Failed to update relay selection', { error })
} finally {
setIsLoading(false)
}
}
updateRelaySelection()
}
}, [
mentions,
isDiscussionReply,
memoizedFavoriteRelays,
memoizedBlockedRelays,
memoizedRelaySets,
_parentEvent,
isPublicMessage,
pubkey,
relayList,
memoizedOpenFrom,
previousSelectableCount,
hasManualSelection,
describeRelaySelection
])
// Update description when selected relays change due to manual selection
useEffect(() => {
if (hasManualSelection && !isLoading) {
setDescription(describeRelaySelection(selectedRelayUrls))
}
}, [selectedRelayUrls, hasManualSelection, isLoading, describeRelaySelection])
// Update parent component with selected relays
useEffect(() => {
// An event is "protected" if we have selected relays that aren't the default user write relays
const defaultUserWriteRelays = [...(relayList?.httpWrite ?? []), ...(relayList?.write || [])]
const normW = (u: string) => normalizeAnyRelayUrl(u) || u
const defaultNorm = new Set(defaultUserWriteRelays.map(normW))
const isProtectedEvent =
selectedRelayUrls.length > 0 &&
!selectedRelayUrls.every((url) => defaultNorm.has(normW(url)))
setIsProtectedEvent(isProtectedEvent)
setAdditionalRelayUrls(selectedRelayUrls)
}, [selectedRelayUrls, relayList, setIsProtectedEvent, setAdditionalRelayUrls])
const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => {
setHasManualSelection(true)
if (checked) {
setSelectedRelayUrls(prev => [...prev, url])
} else {
setSelectedRelayUrls(prev => prev.filter(u => u !== url))
}
}, [])
const handleSelectAll = useCallback(() => {
setHasManualSelection(true)
setSelectedRelayUrls([...selectableRelays])
}, [selectableRelays])
const handleClearAll = useCallback(() => {
setHasManualSelection(true)
setSelectedRelayUrls([])
}, [])
const content = (
<>
{selectableRelays.length > 0 && (
<div className="flex gap-2 mb-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"
>
{t('Clear All')}
</button>
</div>
)}
{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>
) : (
<div className="space-y-1">
{(() => {
// Sort relays so selected ones appear at the top
const sortedRelays = [...selectableRelays].sort((a, b) => {
const aSelected = selectedRelayUrls.includes(a)
const bSelected = selectedRelayUrls.includes(b)
if (aSelected && !bSelected) return -1
if (!aSelected && bSelected) return 1
return 0
})
return sortedRelays.map((url) => {
const isChecked = selectedRelayUrls.includes(url)
const sourceType = relayTypes[url]
const typeLabel = sourceType ? t(`relayType_${sourceType}`) : ''
return (
<div
key={url}
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer touch-manipulation"
onClick={() => handleRelayCheckedChange(!isChecked, url)}
>
<div className="flex items-center justify-center w-4 h-4 border border-border rounded shrink-0">
{isChecked && <Check className="w-3 h-3" />}
</div>
<RelayIcon url={url} className="w-4 h-4 shrink-0" />
<span className="text-sm flex-1 truncate min-w-0">{simplifyUrl(url)}</span>
{typeLabel && (
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{typeLabel}
</span>
)}
</div>
)
})
})()}
</div>
)}
</>
)
// Create compact trigger button text
const triggerText = useMemo(() => {
if (isLoading) return t('Loading...')
if (selectedRelayUrls.length === 0) return t('Select relays')
if (selectedRelayUrls.length === 1) return simplifyUrl(selectedRelayUrls[0])
return t('{{count}} relays', { count: selectedRelayUrls.length })
}, [selectedRelayUrls, isLoading, t])
const capHintEl =
publishCapPreview.showCapHint &&
(publishCapPreview.outboxSlotsInPublish > 0 ? (
<span className="text-xs text-amber-600 dark:text-amber-500">
{t('Publish relay cap hint with outbox first', {
max: MAX_PUBLISH_RELAYS,
reservedSlots: publishCapPreview.outboxSlotsInPublish,
selected: publishCapPreview.selectedTotal,
selectedContacted: publishCapPreview.selectedContacted
})}
</span>
) : (
<span className="text-xs text-amber-600 dark:text-amber-500">
{t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: publishCapPreview.selectedTotal,
selectedContacted: publishCapPreview.selectedContacted
})}
</span>
))
if (isSmallScreen) {
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t('Post to')}</span>
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs justify-between min-w-0 flex-1"
>
<div className="flex items-center gap-2 min-w-0">
<Server className="w-3 h-3 shrink-0" />
<span className="truncate">{triggerText}</span>
</div>
<ChevronDown className="w-3 h-3 shrink-0" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="h-[60vh] p-0">
<div className="flex flex-col h-full">
<div className="p-4 border-b flex items-center justify-between shrink-0 pr-12">
<div className="flex flex-col min-w-0 flex-1 gap-1">
<span className="text-lg font-medium">{t('Select relays')}</span>
<span className="text-sm text-muted-foreground truncate">{description}</span>
{capHintEl}
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-scroll overflow-x-hidden p-4">
{content}
</div>
</div>
</SheetContent>
</Sheet>
</div>
)
}
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t('Post to')}</span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs justify-between min-w-0 flex-1"
>
<div className="flex items-center gap-2 min-w-0">
<Server className="w-3 h-3 shrink-0" />
<span className="truncate">{triggerText}</span>
</div>
<ChevronDown className="w-3 h-3 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[90vw] max-w-md p-0 max-h-[40vh] flex flex-col overflow-hidden" align="start" side="bottom" sideOffset={8}>
<div className="p-3 border-b flex flex-col gap-1 shrink-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{t('Select relays')}</span>
<span className="text-xs text-muted-foreground truncate">{description}</span>
</div>
{capHintEl}
</div>
<div className="max-h-[35vh] min-h-0 overflow-y-scroll overflow-x-hidden p-3">
{content}
</div>
</PopoverContent>
</Popover>
</div>
)
}