diff --git a/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx b/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx new file mode 100644 index 0000000..fd65292 --- /dev/null +++ b/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx @@ -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) => { + setInput(e.target.value) + setErrorMsg('') + setSuccessMsg('') + } + + const handleNewRelayInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + saveRelay() + } + } + + return ( +
+
{t('Block Relay')}
+
+ + +
+ {errorMsg &&
{errorMsg}
} + {successMsg && ( +
+ + {successMsg} +
+ )} +
+ ) +} + diff --git a/src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx b/src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx new file mode 100644 index 0000000..4144c2b --- /dev/null +++ b/src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx @@ -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 ( +
push(toRelay(relay))} + > +
+ +
{relay}
+
+ +
+ ) +} + diff --git a/src/components/FavoriteRelaysSetting/BlockedRelayList.tsx b/src/components/FavoriteRelaysSetting/BlockedRelayList.tsx new file mode 100644 index 0000000..4b5ff43 --- /dev/null +++ b/src/components/FavoriteRelaysSetting/BlockedRelayList.tsx @@ -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 ( +
+
{t('Blocked Relays')}
+
+ {blockedRelays.map((relay) => ( + + ))} +
+
+ ) +} + diff --git a/src/components/FavoriteRelaysSetting/index.tsx b/src/components/FavoriteRelaysSetting/index.tsx index 6944088..cb6934c 100644 --- a/src/components/FavoriteRelaysSetting/index.tsx +++ b/src/components/FavoriteRelaysSetting/index.tsx @@ -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() { + + ) diff --git a/src/components/MailboxSetting/DiscoveredRelays.tsx b/src/components/MailboxSetting/DiscoveredRelays.tsx new file mode 100644 index 0000000..90b6715 --- /dev/null +++ b/src/components/MailboxSetting/DiscoveredRelays.tsx @@ -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([]) + 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() + + 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 ( +
+
{t('Discovered Relays')}
+
+ + {t('Discovering relays...')} +
+
+ ) + } + + if (discoveredRelays.length === 0) { + return null + } + + const selectedCount = discoveredRelays.filter(r => r.selected).length + + return ( +
+
+
{t('Discovered Relays')}
+ +
+ +
+ {t('These relays were found from your NIP-05 identifier and signer. You can add them to your relay list.')} +
+ +
+ {discoveredRelays.map((relay) => ( +
+ handleToggleRelay(relay.url)} + /> + +
+ ))} +
+ +
+
+ + +
+ +
+ + {successMsg && ( +
+ + {successMsg} +
+ )} + {errorMsg && ( +
+ + {errorMsg} +
+ )} +
+ ) +} + diff --git a/src/components/MailboxSetting/index.tsx b/src/components/MailboxSetting/index.tsx index 0746925..74783ab 100644 --- a/src/components/MailboxSetting/index.tsx +++ b/src/components/MailboxSetting/index.tsx @@ -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() { 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 (
@@ -114,6 +125,7 @@ export default function MailboxSetting() {
{t('write relays description')}
{t('read & write relays notice')}
+ ([]) const [isProtectedEvent, setIsProtectedEvent] = useState(false) const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) - const [userWriteRelays, setUserWriteRelays] = useState([]) const [isHighlight, setIsHighlight] = useState(false) const [highlightData, setHighlightData] = useState({ sourceType: 'nostr', @@ -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({ // 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({ )}
diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 9569426..142f45f 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -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' 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> setAdditionalRelayUrls: Dispatch> - setUserWriteRelays?: Dispatch> 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([]) - const [mentionRelays, setMentionRelays] = useState([]) - - // 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([]) + 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 ( -
- {t('No relays available')} -
- ) - } + const handleSelectAll = useCallback(() => { + setSelectedRelayUrls([...selectableRelays]) + }, [selectableRelays]) - return ( -
- {selectableRelays.map((url) => ( - handleRelayCheckedChange(checked, url)} - > -
- -
{simplifyUrl(url)}
-
-
- ))} -
- ) - }, [selectedRelayUrls, selectableRelays]) + const handleClearAll = useCallback(() => { + setSelectedRelayUrls([]) + }, []) - if (isSmallScreen) { - return ( - <> -
- {t('Post to')} - + + {t('Clear All')} +
- - setIsDrawerOpen(false)} /> - -
- {content} -
-
-
- - ) - } - - return ( - -
- {t('Post to')} - - - + )} + +
+ {isLoading ? ( +
{t('Loading relays...')}
+ ) : selectableRelays.length === 0 ? ( +
{t('No relays available')}
+ ) : ( + selectableRelays.map((url) => { + const isChecked = selectedRelayUrls.includes(url) + return ( +
handleRelayCheckedChange(!isChecked, url)} + > +
+ {isChecked && } +
+ + {simplifyUrl(url)} +
+ ) + }) + )}
- - {content} - - +
) -} - - -function MenuItem({ - children, - checked, - onCheckedChange -}: { - children: React.ReactNode - checked: boolean - onCheckedChange: (checked: boolean) => void -}) { - const { isSmallScreen } = useScreenSize() if (isSmallScreen) { return ( -
onCheckedChange(!checked)} - className="flex items-center gap-2 px-4 py-3 clickable" - > -
- {checked && } +
+
+ {t('Post to')} + {description} +
+ + {/* Drawer implementation would go here */} +
+ {content}
- {children}
) } return ( -
onCheckedChange(!checked)} - className="flex items-center gap-2 px-2 py-2 hover:bg-muted cursor-pointer rounded-sm" - > -
- {checked && } +
+
+ {t('Post to')} + {description} +
+ +
+ {content}
- {children}
) -} +} \ No newline at end of file diff --git a/src/components/SaveRelayDropdownMenu/index.tsx b/src/components/SaveRelayDropdownMenu/index.tsx index 698def4..2f8fb3b 100644 --- a/src/components/SaveRelayDropdownMenu/index.tsx +++ b/src/components/SaveRelayDropdownMenu/index.tsx @@ -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({ ))} + +
@@ -100,6 +102,8 @@ export default function SaveRelayDropdownMenu({ ))} + + ) @@ -229,3 +233,50 @@ function SaveToNewSet({ urls }: { urls: string[] }) { ) } + +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 ( + + {isLoading ? : } + {isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')} + + ) + } + + return ( + + {isLoading ? : } + {isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')} + + ) +} diff --git a/src/constants.ts b/src/constants.ts index 7737257..3d30903 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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, diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 69d700e..339c922 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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, diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts index b9ce232..0c80525 100644 --- a/src/lib/nip05.ts +++ b/src/lib/nip05.ts @@ -5,6 +5,7 @@ type TVerifyNip05Result = { isVerified: boolean nip05Name: string nip05Domain: string + relays?: string[] } const verifyNip05ResultCache = new LRUCache({ @@ -17,14 +18,16 @@ const verifyNip05ResultCache = new LRUCache({ async function _verifyNip05(nip05: string, pubkey: string): Promise { 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 return [] } } + +/** + * Attempt to get relays from NIP-07 extension + * Some extensions support a getRelays() method + */ +export async function getRelaysFromNip07Extension(): Promise { + 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 [] +} diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 329319d..e435c42 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -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({ 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([]) + const [selectableRelays, setSelectableRelays] = useState([]) 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({ 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({
- {selectableRelays.length === 0 ? ( + {isLoadingRelays ? ( +
+ {t('Loading relays...')} +
+ ) : selectableRelays.length === 0 ? (
{t('No relays available. Please configure relays in settings.')}
@@ -525,13 +560,8 @@ export default function CreateThreadDialog({ { - if (checked) { - setSelectedRelayUrls(prev => [...prev, relay]) - } else { - setSelectedRelayUrls(prev => prev.filter(url => url !== relay)) - } - }} + onCheckedChange={(checked) => handleRelayCheckedChange(!!checked, relay)} + disabled={isLoadingRelays} />