From 7a4e270ff08e174da2c5010a03f4eb287cb2f4c2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 13 Oct 2025 09:41:06 +0200 Subject: [PATCH] cleaned up replies and mentions --- src/components/PostEditor/PostContent.tsx | 76 ++++++----- .../PostEditor/PostRelaySelector.tsx | 123 ++++++++++++++++-- src/components/RelayStatusDisplay/index.tsx | 79 ++++++++--- src/lib/publishing-feedback.tsx | 4 +- src/providers/NostrProvider/index.tsx | 14 +- src/services/client.service.ts | 76 ++++++++++- src/types/index.d.ts | 1 + 7 files changed, 305 insertions(+), 68 deletions(-) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 9c0d6bc..8226f4e 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -59,7 +59,7 @@ export default function PostContent({ const [isNsfw, setIsNsfw] = useState(false) const [isPoll, setIsPoll] = useState(false) const [isPublicMessage, setIsPublicMessage] = useState(false) - const [publicMessageRecipients, setPublicMessageRecipients] = useState([]) + const [extractedMentions, setExtractedMentions] = useState([]) const [isProtectedEvent, setIsProtectedEvent] = useState(false) const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) const [isHighlight, setIsHighlight] = useState(false) @@ -82,7 +82,7 @@ export default function PostContent({ !posting && !uploadProgresses.length && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && - (!isPublicMessage || publicMessageRecipients.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && + (!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && (!isProtectedEvent || additionalRelayUrls.length > 0) && (!isHighlight || highlightData.sourceValue.trim() !== '') ) @@ -96,7 +96,7 @@ export default function PostContent({ isPoll, pollCreateData, isPublicMessage, - publicMessageRecipients, + extractedMentions, parentEvent?.kind, isProtectedEvent, additionalRelayUrls, @@ -151,25 +151,20 @@ export default function PostContent({ // For now, we'll use the nostr mentions and show that we detected @ mentions // In a real implementation, you'd resolve @ mentions to pubkeys - setPublicMessageRecipients(nostrPubkeys) + setExtractedMentions(nostrPubkeys) } catch (error) { console.error('Error extracting mentions:', error) - setPublicMessageRecipients([]) + setExtractedMentions([]) } }, []) useEffect(() => { - if (!isPublicMessage) { - setPublicMessageRecipients([]) - return - } - if (!text) { - setPublicMessageRecipients([]) + setExtractedMentions([]) return } - // Debounce the mention extraction + // Debounce the mention extraction for all posts (not just public messages) const timeoutId = setTimeout(() => { extractMentionsFromContent(text) }, 300) @@ -177,7 +172,7 @@ export default function PostContent({ return () => { clearTimeout(timeoutId) } - }, [text, isPublicMessage, extractMentionsFromContent]) + }, [text, extractMentionsFromContent]) const post = async (e?: React.MouseEvent) => { e?.stopPropagation() @@ -197,9 +192,11 @@ export default function PostContent({ // }) setPosting(true) + let draftEvent: any = null + let newEvent: any = null + try { - let draftEvent if (isHighlight) { // For highlights, pass the original sourceValue which contains the full identifier // The createHighlightDraftEvent function will parse it correctly @@ -215,7 +212,7 @@ export default function PostContent({ } ) } else if (isPublicMessage) { - draftEvent = await createPublicMessageDraftEvent(text, publicMessageRecipients, { + draftEvent = await createPublicMessageDraftEvent(text, extractedMentions, { addClientTag, isNsfw }) @@ -245,10 +242,11 @@ export default function PostContent({ } // console.log('Publishing draft event:', draftEvent) - const newEvent = await publish(draftEvent, { + newEvent = await publish(draftEvent, { specifiedRelayUrls: additionalRelayUrls.length > 0 ? additionalRelayUrls : undefined, additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls, - minPow + minPow, + disableFallbacks: additionalRelayUrls.length > 0 // Don't use fallbacks if user explicitly selected relays }) // console.log('Published event:', newEvent) @@ -283,6 +281,7 @@ export default function PostContent({ showSimplePublishSuccess(parentEvent ? t('Reply published') : t('Post published')) } + // Full success - clean up and close postEditorCache.clearPostCache({ defaultContent, parentEvent }) deleteDraftEventCache(draftEvent) addReplies([newEvent]) @@ -313,6 +312,15 @@ export default function PostContent({ (parentEvent ? t('Failed to publish reply') : t('Failed to publish post')), duration: 6000 }) + + // Handle partial success + if (successCount > 0) { + // Clean up and close on partial success + postEditorCache.clearPostCache({ defaultContent, parentEvent }) + if (draftEvent) deleteDraftEventCache(draftEvent) + if (newEvent) addReplies([newEvent]) + close() + } } else { // Use standard publishing error feedback for cases without relay statuses if (error instanceof AggregateError) { @@ -323,6 +331,7 @@ export default function PostContent({ } else { showPublishingError('Failed to publish') } + // Don't close form on complete failure - let user try again } } finally { setPosting(false) @@ -443,12 +452,12 @@ export default function PostContent({ - {publicMessageRecipients.length > 0 ? ( + {extractedMentions.length > 0 ? (
- {t('Recipients detected from your message:')} {publicMessageRecipients.length} + {t('Recipients detected from your message:')} {extractedMentions.length}
) : (
@@ -486,14 +495,23 @@ export default function PostContent({
))} {!isPoll && ( - + <> + {console.log('PostContent: Rendering PostRelaySelector with:', { + extractedMentions, + isPublicMessage, + isPoll, + textLength: text.length + })} + + )}
diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 9c54f20..c38e2b6 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -5,7 +5,7 @@ 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, useState } from 'react' +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' @@ -16,7 +16,8 @@ export default function PostRelaySelector({ setIsProtectedEvent, setAdditionalRelayUrls, content: postContent = '', - isPublicMessage = false + isPublicMessage = false, + mentions = [] }: { parentEvent?: NostrEvent openFrom?: string[] @@ -24,6 +25,7 @@ export default function PostRelaySelector({ setAdditionalRelayUrls: Dispatch> content?: string isPublicMessage?: boolean + mentions?: string[] }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() @@ -36,8 +38,38 @@ export default function PostRelaySelector({ const [isLoading, setIsLoading] = useState(true) const [hasManualSelection, setHasManualSelection] = useState(false) const [previousSelectableCount, setPreviousSelectableCount] = useState(0) + const [previousMentions, setPreviousMentions] = useState([]) - // Use centralized relay selection service + // 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) @@ -45,14 +77,14 @@ export default function PostRelaySelector({ const result = await relaySelectionService.selectRelays({ userWriteRelays: relayList?.write || [], userReadRelays: relayList?.read || [], - favoriteRelays, - blockedRelays, - relaySets, + favoriteRelays: memoizedFavoriteRelays, + blockedRelays: memoizedBlockedRelays, + relaySets: memoizedRelaySets, parentEvent: _parentEvent, isPublicMessage, - content: postContent, + content: isDiscussionReply ? '' : postContent, // Don't use content for discussion replies userPubkey: pubkey || undefined, - openFrom + openFrom: memoizedOpenFrom }) const newSelectableCount = result.selectableRelays.length @@ -63,17 +95,16 @@ export default function PostRelaySelector({ // Only update selected relays if: // 1. User hasn't manually modified them, OR - // 2. New mention relays were added (selectable count changed) + // 2. Selectable relays changed if (!hasManualSelection || selectableRelaysChanged) { setSelectedRelayUrls(result.selectedRelays) setDescription(result.description) - // Reset manual selection flag if mentions changed + // Reset manual selection flag if relays changed if (selectableRelaysChanged && hasManualSelection) { setHasManualSelection(false) } } - console.log('PostRelaySelector: Updated relay selection:', result) } catch (error) { console.error('Failed to update relay selection:', error) setSelectableRelays([]) @@ -87,7 +118,75 @@ export default function PostRelaySelector({ } updateRelaySelection() - }, [openFrom, _parentEvent, favoriteRelays, blockedRelays, relaySets, isPublicMessage, postContent, pubkey, relayList]) + }, [memoizedOpenFrom, _parentEvent, memoizedFavoriteRelays, memoizedBlockedRelays, memoizedRelaySets, isPublicMessage, pubkey, relayList, isDiscussionReply]) + + // Separate effect for mention changes in non-discussion replies + useEffect(() => { + console.log('PostRelaySelector: Mentions effect triggered', { + mentions, + previousMentions, + isDiscussionReply, + mentionsLength: mentions.length, + previousMentionsLength: previousMentions.length + }) + + if (isDiscussionReply) { + console.log('PostRelaySelector: Skipping mention update - is discussion reply') + return // Skip for discussion replies + } + + const mentionsChanged = JSON.stringify(mentions) !== JSON.stringify(previousMentions) + console.log('PostRelaySelector: Mentions changed?', mentionsChanged) + + if (mentionsChanged) { + console.log('PostRelaySelector: Updating relay selection due to mention changes') + setPreviousMentions(mentions) + + // Update relay selection when mentions change + const updateRelaySelection = async () => { + setIsLoading(true) + try { + const result = await relaySelectionService.selectRelays({ + userWriteRelays: relayList?.write || [], + userReadRelays: relayList?.read || [], + favoriteRelays: memoizedFavoriteRelays, + blockedRelays: memoizedBlockedRelays, + relaySets: memoizedRelaySets, + parentEvent: _parentEvent, + isPublicMessage, + content: postContent, + 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) { + setSelectedRelayUrls(result.selectedRelays) + setDescription(result.description) + // Reset manual selection flag if relays changed + if (selectableRelaysChanged && hasManualSelection) { + setHasManualSelection(false) + } + } + + } catch (error) { + console.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(() => { diff --git a/src/components/RelayStatusDisplay/index.tsx b/src/components/RelayStatusDisplay/index.tsx index 18e530a..1349b82 100644 --- a/src/components/RelayStatusDisplay/index.tsx +++ b/src/components/RelayStatusDisplay/index.tsx @@ -1,6 +1,49 @@ import { Check, X } from 'lucide-react' import { simplifyUrl } from '@/lib/url' +/** + * Format relay error messages to be more user-friendly + */ +function formatRelayError(error: string): string { + const lowerError = error.toLowerCase() + + // Handle confusing relay error messages + if (lowerError.includes('blocked') && lowerError.includes('event marked as protected')) { + return 'Relay rejected this content (may be due to content policy)' + } + + if (lowerError.includes('blocked')) { + return 'Relay blocked this content' + } + + if (lowerError.includes('rate limit') || lowerError.includes('rate-limit')) { + return 'Rate limited - please wait before trying again' + } + + if (lowerError.includes('auth') && lowerError.includes('required')) { + return 'Authentication required' + } + + if (lowerError.includes('writes disabled') || lowerError.includes('write disabled')) { + return 'Relay has temporarily disabled writes' + } + + if (lowerError.includes('invalid key')) { + return 'Authentication failed - invalid key' + } + + if (lowerError.includes('timeout')) { + return 'Request timed out' + } + + if (lowerError.includes('connection') && lowerError.includes('refused')) { + return 'Connection refused by relay' + } + + // Return original error if no specific formatting applies + return error +} + interface RelayStatus { url: string success: boolean @@ -31,13 +74,13 @@ export default function RelayStatusDisplay({ Published to {successCount} of {totalCount} relays
-
+
{relayStatuses.map((status, index) => (
-
+
{status.success ? ( ) : ( @@ -45,23 +88,25 @@ export default function RelayStatusDisplay({ )}
-
-
- - {simplifyUrl(status.url)} - - {status.authAttempted && !status.success && ( - - (auth failed) +
+
+
+ + {simplifyUrl(status.url)} + {status.authAttempted && !status.success && ( + + (auth failed) + + )} +
+ + {!status.success && status.error && ( +
+ {formatRelayError(status.error)} +
)}
- - {!status.success && status.error && ( -
- {status.error} -
- )}
))} diff --git a/src/lib/publishing-feedback.tsx b/src/lib/publishing-feedback.tsx index 5920709..b93347b 100644 --- a/src/lib/publishing-feedback.tsx +++ b/src/lib/publishing-feedback.tsx @@ -43,7 +43,7 @@ export function showPublishingFeedback( const toastFunction = isSuccess ? toast.success : toast.error toastFunction( -
+
{message}
@@ -59,7 +59,7 @@ export function showPublishingFeedback(
, { duration, - className: 'max-w-md' + className: 'max-w-lg w-full' } ) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 0700875..18f1c5b 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -772,13 +772,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const relays = await client.determineTargetRelays(event, options) try { - const publishResult = await client.publishEvent(relays, event) + const publishResult = await client.publishEvent(relays, event, { + disableFallbacks: options.disableFallbacks + }) // Store relay status for display if (publishResult.relayStatuses.length > 0) { (event as any).relayStatuses = publishResult.relayStatuses } + // If publishing failed completely, throw an error so the form doesn't close + if (!publishResult.success) { + const error = new AggregateError( + publishResult.relayStatuses.map(s => new Error(s.error || 'Failed')), + 'Failed to publish to any relay' + ) + ;(error as any).relayStatuses = publishResult.relayStatuses + throw error + } + return event } catch (error) { // Check for authentication-related errors diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 9708956..83bf620 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -100,10 +100,16 @@ class ClientService extends EventTarget { if (specifiedRelayUrls?.length && (event.kind === ExtendedKind.DISCUSSION || event.kind === ExtendedKind.COMMENT)) { // For discussion replies, try ONLY the specified relay first // The fallback will be handled in the publishing logic if this fails - relays = specifiedRelayUrls + // But still filter blocked relays from specified relays + if (this.pubkey) { + const blockedRelays = await this.fetchBlockedRelays(this.pubkey) + relays = this.filterBlockedRelays(specifiedRelayUrls, blockedRelays) + } else { + relays = specifiedRelayUrls + } return relays } else if (specifiedRelayUrls?.length) { - // For non-discussion events, use specified relays as-is + // For non-discussion events, use specified relays (will be filtered below) relays = specifiedRelayUrls } else { const _additionalRelayUrls: string[] = additionalRelayUrls ?? [] @@ -155,10 +161,16 @@ class ClientService extends EventTarget { relays.push(...FAST_WRITE_RELAY_URLS) } + // Filter out blocked relays + if (this.pubkey) { + const blockedRelays = await this.fetchBlockedRelays(this.pubkey) + relays = this.filterBlockedRelays(relays, blockedRelays) + } + return relays } - async publishEvent(relayUrls: string[], event: NEvent): Promise<{ + async publishEvent(relayUrls: string[], event: NEvent, options: { disableFallbacks?: boolean } = {}): Promise<{ success: boolean relayStatuses: Array<{ url: string @@ -170,7 +182,8 @@ class ClientService extends EventTarget { totalCount: number }> { // Special handling for discussion events: try relay hint first, then fallback - if ((event.kind === ExtendedKind.DISCUSSION || event.kind === ExtendedKind.COMMENT) && relayUrls.length === 1) { + // BUT: if disableFallbacks is true (user explicitly selected relays), don't use fallbacks + if ((event.kind === ExtendedKind.DISCUSSION || event.kind === ExtendedKind.COMMENT) && relayUrls.length === 1 && !options.disableFallbacks) { try { // Try publishing to the relay hint first const result = await this._publishToRelays(relayUrls, event) @@ -180,9 +193,11 @@ class ClientService extends EventTarget { return result } - // If failed, try fallback relays + // If failed, try fallback relays (filtering out blocked relays) const userRelays = this.pubkey ? await this.fetchRelayList(this.pubkey) : { write: [], read: [] } - const fallbackRelays = userRelays.write.length > 0 ? userRelays.write.slice(0, 3) : FAST_WRITE_RELAY_URLS + const blockedRelays = this.pubkey ? await this.fetchBlockedRelays(this.pubkey) : [] + let fallbackRelays = userRelays.write.length > 0 ? userRelays.write.slice(0, 3) : FAST_WRITE_RELAY_URLS + fallbackRelays = this.filterBlockedRelays(fallbackRelays, blockedRelays) console.log('Relay hint failed, trying fallback relays:', fallbackRelays) const fallbackResult = await this._publishToRelays(fallbackRelays, event) @@ -208,7 +223,9 @@ class ClientService extends EventTarget { } const userRelays = this.pubkey ? await this.fetchRelayList(this.pubkey) : { write: [], read: [] } - const fallbackRelays = userRelays.write.length > 0 ? userRelays.write.slice(0, 3) : FAST_WRITE_RELAY_URLS + const blockedRelays = this.pubkey ? await this.fetchBlockedRelays(this.pubkey) : [] + let fallbackRelays = userRelays.write.length > 0 ? userRelays.write.slice(0, 3) : FAST_WRITE_RELAY_URLS + fallbackRelays = this.filterBlockedRelays(fallbackRelays, blockedRelays) console.log('Trying fallback relays:', fallbackRelays) const fallbackResult = await this._publishToRelays(fallbackRelays, event) @@ -243,6 +260,13 @@ class ClientService extends EventTarget { }> { const uniqueRelayUrls = this.optimizeRelaySelection(Array.from(new Set(relayUrls))) + // Handle case where no relays are available (all filtered out) + if (uniqueRelayUrls.length === 0) { + const error = new Error('No relays available for publishing - all relays may be blocked or unavailable') + ;(error as any).relayStatuses = [] + throw error + } + const relayStatuses: Array<{ url: string success: boolean @@ -1515,6 +1539,44 @@ class ClientService extends EventTarget { await this.updateReplaceableEventFromBigRelaysCache(event) } + /** + * Fetch blocked relays from IndexedDB + */ + async fetchBlockedRelays(pubkey: string): Promise { + try { + const blockedRelaysEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.BLOCKED_RELAYS) + if (!blockedRelaysEvent) { + return [] + } + + // Extract relay URLs from the relay tags + const relayUrls = blockedRelaysEvent.tags + .filter(([tagName]) => tagName === 'relay') + .map(([, url]) => url) + .filter(Boolean) + + return relayUrls + } catch (error) { + console.error('Failed to fetch blocked relays:', error) + return [] + } + } + + /** + * Filter out blocked relays from a relay list + */ + private filterBlockedRelays(relays: string[], blockedRelays: string[]): string[] { + if (!blockedRelays || blockedRelays.length === 0) { + return relays + } + + const normalizedBlocked = blockedRelays.map(url => normalizeUrl(url) || url) + return relays.filter(relay => { + const normalizedRelay = normalizeUrl(relay) || relay + return !normalizedBlocked.includes(normalizedRelay) + }) + } + /** =========== Replaceable event from big relays dataloader =========== */ private replaceableEventFromBigRelaysDataloader = new DataLoader< diff --git a/src/types/index.d.ts b/src/types/index.d.ts index e1e48f8..838fd24 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -123,6 +123,7 @@ export type TPublishOptions = { specifiedRelayUrls?: string[] additionalRelayUrls?: string[] minPow?: number + disableFallbacks?: boolean // If true, don't use fallback relays when publishing fails } export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you'