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.
422 lines
18 KiB
422 lines
18 KiB
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<SetStateAction<boolean>> |
|
setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>> |
|
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<string[]>([]) |
|
const [selectableRelays, setSelectableRelays] = useState<string[]>([]) |
|
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]) |
|
|
|
// 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 && ( |
|
<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) |
|
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"> |
|
{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> |
|
)} |
|
</> |
|
) |
|
|
|
// 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 ( |
|
<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"> |
|
<span className="text-lg font-medium">{t('Select relays')}</span> |
|
<span className="text-sm text-muted-foreground truncate">{description}</span> |
|
</div> |
|
</div> |
|
<ScrollArea className="flex-1 p-4"> |
|
{content} |
|
</ScrollArea> |
|
</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" align="start" side="bottom" sideOffset={8}> |
|
<div className="p-3 border-b flex items-center justify-between shrink-0"> |
|
<span className="text-sm font-medium">{t('Select relays')}</span> |
|
<span className="text-xs text-muted-foreground truncate ml-2">{description}</span> |
|
</div> |
|
<ScrollArea className="p-3 max-h-[30vh]"> |
|
{content} |
|
</ScrollArea> |
|
</PopoverContent> |
|
</Popover> |
|
</div> |
|
) |
|
} |