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> setAdditionalRelayUrls: Dispatch> 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([]) const [selectableRelays, setSelectableRelays] = useState([]) const [relayTypes, setRelayTypes] = useState>({}) const [description, setDescription] = useState('') const [isLoading, setIsLoading] = useState(true) const [hasManualSelection, setHasManualSelection] = useState(false) const [previousSelectableCount, setPreviousSelectableCount] = useState(0) const [previousMentions, setPreviousMentions] = useState([]) // 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 && (
)} {isLoading ? (
{t('Loading relays...')}
) : selectableRelays.length === 0 ? (
{t('No relays available')}
) : (
{(() => { // 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 (
handleRelayCheckedChange(!isChecked, url)} >
{isChecked && }
{simplifyUrl(url)} {typeLabel && ( {typeLabel} )}
) }) })()}
)} ) // 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 ? ( {t('Publish relay cap hint with outbox first', { max: MAX_PUBLISH_RELAYS, reservedSlots: publishCapPreview.outboxSlotsInPublish, selected: publishCapPreview.selectedTotal, selectedContacted: publishCapPreview.selectedContacted })} ) : ( {t('Publish relay cap hint', { max: MAX_PUBLISH_RELAYS, selected: publishCapPreview.selectedTotal, selectedContacted: publishCapPreview.selectedContacted })} )) if (isSmallScreen) { return (
{t('Post to')}
{t('Select relays')} {description} {capHintEl}
{content}
) } return (
{t('Post to')}
{t('Select relays')} {description}
{capHintEl}
{content}
) }