import { simplifyUrl, isLocalNetworkUrl, 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 { ExtendedKind } from '@/constants' 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 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 { ScrollArea } from '@/components/ui/scroll-area' import logger from '@/lib/logger' export default function PostRelaySelector({ parentEvent: _parentEvent, openFrom, setIsProtectedEvent, setAdditionalRelayUrls, content: postContent = '', isPublicMessage = false, mentions = [] }: { parentEvent?: NostrEvent openFrom?: string[] setIsProtectedEvent: Dispatch> setAdditionalRelayUrls: Dispatch> content?: string isPublicMessage?: boolean mentions?: string[] }) { const { t } = useTranslation() 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 [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]) // Memoize arrays to prevent unnecessary re-renders const memoizedFavoriteRelays = useMemo(() => favoriteRelays, [favoriteRelays]) const memoizedBlockedRelays = useMemo(() => blockedRelays, [blockedRelays]) 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, 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) 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(result.description) // 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('No relays selected') } } finally { setIsLoading(false) } } updateRelaySelection() }, [memoizedOpenFrom, _parentEvent, memoizedFavoriteRelays, memoizedBlockedRelays, memoizedRelaySets, isPublicMessage, pubkey, relayList, isDiscussionReply, postContent, mentions]) // 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, 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) 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(result.description) // 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, postContent]) // Update description when selected relays change due to manual selection useEffect(() => { if (hasManualSelection && !isLoading) { const count = selectedRelayUrls.length setDescription(count === 0 ? 'No relays selected' : count === 1 ? simplifyUrl(selectedRelayUrls[0]) : `${count} relays`) } }, [selectedRelayUrls, hasManualSelection, isLoading]) // 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 userWriteRelays = relayList?.write || [] const isProtectedEvent = selectedRelayUrls.length > 0 && !selectedRelayUrls.every(url => userWriteRelays.includes(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) return (
handleRelayCheckedChange(!isChecked, url)} >
{isChecked && }
{simplifyUrl(url)}
) }) })()}
)} ) // 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]) if (isSmallScreen) { return (
{t('Post to')}
{t('Select relays')} {description}
{content}
) } return (
{t('Post to')}
{t('Select relays')} {description}
{content}
) }