diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 13e9c45..35b06ff 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -27,6 +27,7 @@ import PostOptions from './PostOptions' import PostRelaySelector from './PostRelaySelector' import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import Uploader from './Uploader' +import RelayStatusDisplay from '@/components/RelayStatusDisplay' export default function PostContent({ defaultContent = '', @@ -64,6 +65,14 @@ export default function PostContent({ relays: [] }) const [minPow, setMinPow] = useState(0) + const [relayStatuses, setRelayStatuses] = useState>([]) + const [showRelayStatus, setShowRelayStatus] = useState(false) + const [lastPublishedEvent, setLastPublishedEvent] = useState(null) const isFirstRender = useRef(true) const canPost = useMemo(() => { const result = ( @@ -239,24 +248,86 @@ export default function PostContent({ minPow }) // console.log('Published event:', newEvent) - postEditorCache.clearPostCache({ defaultContent, parentEvent }) - deleteDraftEventCache(draftEvent) - addReplies([newEvent]) - close() + + // Check if we have relay status information + console.log('Published event:', newEvent) + console.log('Relay statuses:', (newEvent as any).relayStatuses) + + if ((newEvent as any).relayStatuses) { + setRelayStatuses((newEvent as any).relayStatuses) + setLastPublishedEvent(newEvent) + setShowRelayStatus(true) + + // Show success message with relay count + const successCount = (newEvent as any).relayStatuses.filter((s: any) => s.success).length + const totalCount = (newEvent as any).relayStatuses.length + toast.success(t('Post successful - published to {{count}} of {{total}} relays', { + count: successCount, + total: totalCount + }), { duration: 4000 }) + + // Don't close immediately if we have relay status to show + setTimeout(() => { + postEditorCache.clearPostCache({ defaultContent, parentEvent }) + deleteDraftEventCache(draftEvent) + addReplies([newEvent]) + close() + }, 8000) // Give user more time to see the relay status + } else { + toast.success(t('Post successful'), { duration: 2000 }) + postEditorCache.clearPostCache({ defaultContent, parentEvent }) + deleteDraftEventCache(draftEvent) + addReplies([newEvent]) + close() + } } catch (error) { - const errors = error instanceof AggregateError ? error.errors : [error] - errors.forEach((err) => { - toast.error( - `${t('Failed to post')}: ${err instanceof Error ? err.message : String(err)}`, - { duration: 10_000 } + console.error('Publishing error:', error) + + // Handle different types of errors with user-friendly messages + let errorMessage = t('Failed to post') + + if (error instanceof Error) { + if (error.message.includes('timeout')) { + errorMessage = t('Posting timed out. Your post may have been published to some relays.') + } else if (error.message.includes('auth-required') || error.message.includes('auth required')) { + errorMessage = t('Some relays require authentication. Please try again or use different relays.') + } else if (error.message.includes('blocked')) { + errorMessage = t('You are blocked from posting to some relays.') + } else if (error.message.includes('rate limit')) { + errorMessage = t('Rate limited. Please wait before trying again.') + } else if (error.message.includes('writes disabled')) { + errorMessage = t('Some relays have temporarily disabled writes.') + } else { + errorMessage = `${t('Failed to post')}: ${error.message}` + } + } else if (error instanceof AggregateError) { + // Handle multiple relay failures + const hasAuthErrors = error.errors.some(err => + err instanceof Error && err.message.includes('auth-required') ) - console.error(err) - }) + const hasBlockedErrors = error.errors.some(err => + err instanceof Error && err.message.includes('blocked') + ) + const hasWriteDisabledErrors = error.errors.some(err => + err instanceof Error && err.message.includes('writes disabled') + ) + + if (hasAuthErrors) { + errorMessage = t('Some relays require authentication. Your post may have been published to other relays.') + } else if (hasBlockedErrors) { + errorMessage = t('You are blocked from some relays. Your post may have been published to other relays.') + } else if (hasWriteDisabledErrors) { + errorMessage = t('Some relays have disabled writes. Your post may have been published to other relays.') + } else { + errorMessage = t('Failed to publish to some relays. Your post may have been published to other relays.') + } + } + + toast.error(errorMessage, { duration: 8000 }) return } finally { setPosting(false) } - toast.success(t('Post successful'), { duration: 2000 }) }) } @@ -507,6 +578,44 @@ export default function PostContent({ {parentEvent ? t('Reply') : t('Post')} + + {showRelayStatus && relayStatuses.length > 0 && ( +
+
+

+ 📡 Publishing Results +

+

+ Your post has been published. Here's the status for each relay: +

+
+ s.success).length} + totalCount={relayStatuses.length} + /> +
+
+ This dialog will close automatically in a few seconds +
+ +
+
+ )} ) } diff --git a/src/components/RelayStatusDisplay/index.tsx b/src/components/RelayStatusDisplay/index.tsx new file mode 100644 index 0000000..b91fe77 --- /dev/null +++ b/src/components/RelayStatusDisplay/index.tsx @@ -0,0 +1,71 @@ +import { Check, X, AlertCircle } from 'lucide-react' +import { simplifyUrl } from '@/lib/url' + +interface RelayStatus { + url: string + success: boolean + error?: string + authAttempted?: boolean +} + +interface RelayStatusDisplayProps { + relayStatuses: RelayStatus[] + successCount: number + totalCount: number + className?: string +} + +export default function RelayStatusDisplay({ + relayStatuses, + successCount, + totalCount, + className = '' +}: RelayStatusDisplayProps) { + if (relayStatuses.length === 0) { + return null + } + + return ( +
+
+ Published to {successCount} of {totalCount} relays +
+ +
+ {relayStatuses.map((status, index) => ( +
+
+ {status.success ? ( + + ) : ( + + )} +
+ +
+
+ + {simplifyUrl(status.url)} + + {status.authAttempted && ( +
+ +
+ )} +
+ + {!status.success && status.error && ( +
+ {status.error} +
+ )} +
+
+ ))} +
+
+ ) +} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index a407197..5c406df 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -631,7 +631,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const relays = await client.determineTargetRelays(event, options) - await client.publishEvent(relays, event) + const publishResult = await client.publishEvent(relays, event) + + console.log('Publish result:', publishResult) + + // Store relay status for display + if (publishResult.relayStatuses.length > 0) { + // We'll pass this to the UI components that need it + (event as any).relayStatuses = publishResult.relayStatuses + console.log('Attached relay statuses to event:', (event as any).relayStatuses) + } + return event } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 59b748e..53a7056 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -153,7 +153,17 @@ class ClientService extends EventTarget { return relays } - async publishEvent(relayUrls: string[], event: NEvent) { + async publishEvent(relayUrls: string[], event: NEvent): Promise<{ + success: boolean + relayStatuses: Array<{ + url: string + success: boolean + error?: string + authAttempted?: boolean + }> + successCount: number + totalCount: number + }> { const uniqueRelayUrls = this.optimizeRelaySelection(Array.from(new Set(relayUrls))) console.log(`Publishing kind ${event.kind} event to ${uniqueRelayUrls.length} relays`) // if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { @@ -166,70 +176,173 @@ class ClientService extends EventTarget { // }) // } - await new Promise((resolve, reject) => { + const relayStatuses: Array<{ + url: string + success: boolean + error?: string + authAttempted?: boolean + }> = [] + + const result = await new Promise<{ + success: boolean + relayStatuses: typeof relayStatuses + successCount: number + totalCount: number + }>((resolve, reject) => { let successCount = 0 let finishedCount = 0 const errors: { url: string; error: any }[] = [] + let resolved = false + + const checkCompletion = () => { + if (resolved) return + + // If one third of the relays have accepted the event, consider it a success + const isSuccess = successCount >= Math.max(1, Math.ceil(uniqueRelayUrls.length / 3)) + if (isSuccess && !resolved) { + console.log(`✓ Publishing successful (${successCount}/${uniqueRelayUrls.length} relays)`) + this.emitNewEvent(event) + resolved = true + resolve({ + success: true, + relayStatuses, + successCount, + totalCount: uniqueRelayUrls.length + }) + return + } + + if (finishedCount >= uniqueRelayUrls.length && !resolved) { + if (successCount > 0) { + console.log(`✓ Publishing successful (${successCount}/${uniqueRelayUrls.length} relays)`) + this.emitNewEvent(event) + resolved = true + resolve({ + success: true, + relayStatuses, + successCount, + totalCount: uniqueRelayUrls.length + }) + } else { + console.log(`✗ Publishing failed (0/${uniqueRelayUrls.length} relays)`) + resolved = true + reject( + new AggregateError( + errors.map( + ({ url, error }) => + new Error( + `${url}: ${error instanceof Error ? error.message : String(error)}` + ) + ) + ) + ) + } + } + } + + // Add overall timeout to prevent hanging + const overallTimeout = setTimeout(() => { + if (!resolved) { + console.log(`⚠ Publishing timeout after 15s (${successCount}/${uniqueRelayUrls.length} relays succeeded)`) + resolved = true + if (successCount > 0) { + this.emitNewEvent(event) + resolve({ + success: true, + relayStatuses, + successCount, + totalCount: uniqueRelayUrls.length + }) + } else { + reject(new Error('Publishing timeout - no relays responded in time')) + } + } + }, 15_000) // 15 second overall timeout + Promise.allSettled( uniqueRelayUrls.map(async (url) => { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this - const relay = await this.pool.ensureRelay(url) - relay.publishTimeout = 8_000 // 8s - return relay - .publish(event) - .then(() => { - console.log(`✓ Published to ${url}`) - this.trackEventSeenOn(event.id, relay) - successCount++ + + try { + const relay = await this.pool.ensureRelay(url) + relay.publishTimeout = 8_000 // 8s + + await relay.publish(event) + console.log(`✓ Published to ${url}`) + this.trackEventSeenOn(event.id, relay) + successCount++ + finishedCount++ + + relayStatuses.push({ + url, + success: true }) - .catch((error) => { - console.log(`✗ Failed to publish to ${url}:`, error.message) - if ( - error instanceof Error && - error.message.startsWith('auth-required') && - !!that.signer - ) { + + checkCompletion() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.log(`✗ Failed to publish to ${url}:`, errorMessage) + + // Check if this is an auth-required error and we have a signer + if ( + error instanceof Error && + error.message.startsWith('auth-required') && + !!that.signer + ) { + try { console.log(`Attempting auth for ${url}`) - return relay - .auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) - .then(() => relay.publish(event)) - } else { - errors.push({ url, error }) + const relay = await this.pool.ensureRelay(url) + await relay.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) + await relay.publish(event) + console.log(`✓ Published to ${url} after auth`) + this.trackEventSeenOn(event.id, relay) + successCount++ + finishedCount++ + + relayStatuses.push({ + url, + success: true, + authAttempted: true + }) + + checkCompletion() + } catch (authError) { + const authErrorMessage = authError instanceof Error ? authError.message : String(authError) + console.log(`✗ Auth failed for ${url}:`, authErrorMessage) + errors.push({ url, error: authError }) + finishedCount++ + + relayStatuses.push({ + url, + success: false, + error: authErrorMessage, + authAttempted: true + }) + + checkCompletion() } - }) - .finally(() => { + } else { + // For permanent errors like "blocked" or "writes disabled", don't retry + errors.push({ url, error }) finishedCount++ - // If one third of the relays have accepted the event, consider it a success - const isSuccess = successCount >= uniqueRelayUrls.length / 3 - if (isSuccess) { - console.log(`✓ Publishing successful (${successCount}/${uniqueRelayUrls.length} relays)`) - this.emitNewEvent(event) - resolve() - } - if (finishedCount >= uniqueRelayUrls.length) { - if (successCount > 0) { - console.log(`✓ Publishing successful (${successCount}/${uniqueRelayUrls.length} relays)`) - this.emitNewEvent(event) - resolve() - } else { - console.log(`✗ Publishing failed (0/${uniqueRelayUrls.length} relays)`) - reject( - new AggregateError( - errors.map( - ({ url, error }) => - new Error( - `${url}: ${error instanceof Error ? error.message : String(error)}` - ) - ) - ) - ) - } - } - }) + + relayStatuses.push({ + url, + success: false, + error: errorMessage + }) + + checkCompletion() + } + } }) - ) + ).finally(() => { + clearTimeout(overallTimeout) + }) }) + + return result } emitNewEvent(event: NEvent) { @@ -1376,8 +1489,34 @@ class ClientService extends EventTarget { // ================= Performance Optimization ================= private optimizeRelaySelection(relays: string[]): string[] { + // Filter out invalid or problematic relay URLs + const validRelays = relays.filter(url => { + try { + // Skip empty or invalid URLs + if (!url || typeof url !== 'string') return false + + // Skip localhost URLs that might be misconfigured + if (url.includes('localhost:7777')) { + console.warn(`Skipping potentially misconfigured relay: ${url}`) + return false + } + + // Validate websocket URL format + if (!isWebsocketUrl(url)) return false + + // Skip URLs that are clearly invalid + const normalizedUrl = normalizeUrl(url) + if (!normalizedUrl) return false + + return true + } catch (error) { + console.warn(`Skipping invalid relay URL: ${url}`, error) + return false + } + }) + // Limit to 4 relays for better performance - return relays.slice(0, 4) + return validRelays.slice(0, 4) } // ================= Utils =================