From 401219ef7dc470eb5bb7a9bf0b88fd60c624e62f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 10 Oct 2025 22:12:01 +0200 Subject: [PATCH] fix publishing --- src/components/PostEditor/PostContent.tsx | 38 +++- src/lib/draft-event.ts | 18 +- src/lib/event-metadata.ts | 3 - src/lib/nostr-address.ts | 71 ++++++++ src/lib/publishing-feedback.tsx | 7 +- .../DiscussionsPage/CreateThreadDialog.tsx | 16 +- src/providers/NostrProvider/index.tsx | 33 ++-- src/services/client.service.ts | 171 ++++++++++-------- src/services/local-storage.service.ts | 2 +- 9 files changed, 258 insertions(+), 101 deletions(-) create mode 100644 src/lib/nostr-address.ts diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 286a3be..1f85852 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -19,7 +19,7 @@ import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X } fr import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' +import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import EmojiPickerDialog from '../EmojiPickerDialog' import Mentions, { extractMentions } from './Mentions' import PollEditor from './PollEditor' @@ -261,7 +261,41 @@ export default function PostContent({ close() } catch (error) { console.error('Publishing error:', error) - // Publishing errors are handled via relay status feedback + console.error('Error details:', { + name: error instanceof Error ? error.name : 'Unknown', + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }) + + // Check if we have relay statuses to display (even if publishing failed) + if (error instanceof AggregateError && (error as any).relayStatuses) { + const relayStatuses = (error as any).relayStatuses + const successCount = relayStatuses.filter((s: any) => s.success).length + const totalCount = relayStatuses.length + + // Show proper relay status feedback + showPublishingFeedback({ + success: successCount > 0, + relayStatuses, + successCount, + totalCount + }, { + message: successCount > 0 ? + (parentEvent ? t('Reply published to some relays') : t('Post published to some relays')) : + (parentEvent ? t('Failed to publish reply') : t('Failed to publish post')), + duration: 6000 + }) + } else { + // Use standard publishing error feedback for cases without relay statuses + if (error instanceof AggregateError) { + const errorMessages = error.errors.map((err: any) => err.message).join('; ') + showPublishingError(`Failed to publish to relays: ${errorMessages}`) + } else if (error instanceof Error) { + showPublishingError(error.message) + } else { + showPublishingError('Failed to publish') + } + } } finally { setPosting(false) } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 1eb3708..9675070 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -2,6 +2,7 @@ import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } fro import client from '@/services/client.service' import customEmojiService from '@/services/custom-emoji.service' import mediaUpload from '@/services/media-upload.service' +import { prefixNostrAddresses } from '@/lib/nostr-address' import { TDraftEvent, TEmoji, @@ -112,7 +113,9 @@ export async function createShortTextNoteDraftEvent( isNsfw?: boolean } = {} ): Promise { - const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) + // Process content to prefix nostr addresses before other transformations + const contentWithPrefixedAddresses = prefixNostrAddresses(content) + const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(contentWithPrefixedAddresses) const { quoteEventHexIds, quoteReplaceableCoordinates, rootETag, parentETag } = await extractRelatedEventIds(transformedEmojisContent, options.parentEvent) const hashtags = extractHashtags(transformedEmojisContent) @@ -175,6 +178,7 @@ export function createRelaySetDraftEvent(relaySet: Omit): TDr } } + export async function createCommentDraftEvent( content: string, parentEvent: Event, @@ -185,7 +189,9 @@ export async function createCommentDraftEvent( isNsfw?: boolean } = {} ): Promise { - const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) + // Process content to prefix nostr addresses before other transformations + const contentWithPrefixedAddresses = prefixNostrAddresses(content) + const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(contentWithPrefixedAddresses) const { quoteEventHexIds, quoteReplaceableCoordinates, @@ -265,7 +271,9 @@ export async function createPublicMessageReplyDraftEvent( isNsfw?: boolean } = {} ): Promise { - const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) + // Process content to prefix nostr addresses before other transformations + const contentWithPrefixedAddresses = prefixNostrAddresses(content) + const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(contentWithPrefixedAddresses) const { quoteEventHexIds, quoteReplaceableCoordinates @@ -332,7 +340,9 @@ export async function createPublicMessageDraftEvent( isNsfw?: boolean } = {} ): Promise { - const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) + // Process content to prefix nostr addresses before other transformations + const contentWithPrefixedAddresses = prefixNostrAddresses(content) + const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(contentWithPrefixedAddresses) const hashtags = extractHashtags(transformedEmojisContent) const tags = emojiTags diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 8ef8174..b992258 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -158,10 +158,7 @@ export function getZapInfoFromEvent(receiptEvent: Event) { if (amountTag && amountTag[1]) { const millisats = parseInt(amountTag[1]) amount = millisats / 1000 // Convert millisats to sats - console.log(`📝 Parsed amount from description tag: ${amountTag[1]} millisats = ${amount} sats`) } - } else if (amount > 0) { - console.log(`📝 Parsed amount from invoice: ${amount} sats`) } } catch { // ignore diff --git a/src/lib/nostr-address.ts b/src/lib/nostr-address.ts new file mode 100644 index 0000000..6128aa9 --- /dev/null +++ b/src/lib/nostr-address.ts @@ -0,0 +1,71 @@ +/** + * Utility functions for handling nostr addresses + */ + +/** + * Regex pattern for matching nostr addresses that don't already have a prefix + * Matches npub, nprofile, note, nevent, naddr, nrelay patterns + */ +const NOSTR_ADDRESS_REGEX = /\b(npub|nprofile|note|nevent|naddr|nrelay)1[a-z0-9]+/gi + +/** + * Prefixes nostr addresses with "nostr:" if they don't already have a prefix + * @param content - The content to process + * @returns The content with nostr addresses properly prefixed + */ +export function prefixNostrAddresses(content: string): string { + return content.replace(NOSTR_ADDRESS_REGEX, (match) => { + // Check if it already has a prefix (nostr: or other protocol) + const beforeMatch = content.substring(0, content.indexOf(match)) + const lastSpace = beforeMatch.lastIndexOf(' ') + const lastNewline = beforeMatch.lastIndexOf('\n') + const lastDelimiter = Math.max(lastSpace, lastNewline) + + if (lastDelimiter >= 0) { + const prefix = content.substring(lastDelimiter + 1, content.indexOf(match)) + // If it already has nostr: prefix, don't add another + if (prefix.includes('nostr:')) { + return match + } + } + + // Add nostr: prefix + return `nostr:${match}` + }) +} + +/** + * Checks if a string contains nostr addresses that need prefixing + * @param content - The content to check + * @returns True if the content contains unprefixed nostr addresses + */ +export function containsUnprefixedNostrAddresses(content: string): boolean { + return NOSTR_ADDRESS_REGEX.test(content) +} + +/** + * Extracts all nostr addresses from content (both prefixed and unprefixed) + * @param content - The content to extract addresses from + * @returns Array of nostr addresses found + */ +export function extractNostrAddresses(content: string): string[] { + // Reset regex state + NOSTR_ADDRESS_REGEX.lastIndex = 0 + + const addresses: string[] = [] + let match + + while ((match = NOSTR_ADDRESS_REGEX.exec(content)) !== null) { + addresses.push(match[0]) + } + + // Also check for already prefixed addresses + const prefixedRegex = /\bnostr:(npub|nprofile|note|nevent|naddr|nrelay)1[a-z0-9]+/gi + prefixedRegex.lastIndex = 0 + + while ((match = prefixedRegex.exec(content)) !== null) { + addresses.push(match[0]) + } + + return addresses +} diff --git a/src/lib/publishing-feedback.tsx b/src/lib/publishing-feedback.tsx index 8e6202f..5920709 100644 --- a/src/lib/publishing-feedback.tsx +++ b/src/lib/publishing-feedback.tsx @@ -39,10 +39,13 @@ export function showPublishingFeedback( } // Show toast with custom relay status display - toast.success( + const isSuccess = successCount > 0 + const toastFunction = isSuccess ? toast.success : toast.error + + toastFunction(
- +
{message}
diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index a6853ec..52db94b 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -12,6 +12,8 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import { TDraftEvent } from '@/types' +import { prefixNostrAddresses } from '@/lib/nostr-address' +import { showPublishingError } from '@/lib/publishing-feedback' import dayjs from 'dayjs' // Utility functions for thread creation @@ -32,6 +34,7 @@ function buildClientTag(): string[] { return ['client', 'jumble'] } + interface CreateThreadDialogProps { topic: string availableRelays: string[] @@ -125,7 +128,7 @@ export default function CreateThreadDialog({ e.preventDefault() if (!pubkey) { - alert(t('You must be logged in to create a thread')) + showPublishingError(t('You must be logged in to create a thread')) return } @@ -136,8 +139,11 @@ export default function CreateThreadDialog({ setIsSubmitting(true) try { - // Extract images from content - const images = extractImagesFromContent(content.trim()) + // Process content to prefix nostr addresses + const processedContent = prefixNostrAddresses(content.trim()) + + // Extract images from processed content + const images = extractImagesFromContent(processedContent) // Build tags array const tags = [ @@ -171,7 +177,7 @@ export default function CreateThreadDialog({ // Create the thread event (kind 11) const threadEvent: TDraftEvent = { kind: 11, - content: content.trim(), + content: processedContent, tags, created_at: dayjs().unix() } @@ -219,7 +225,7 @@ export default function CreateThreadDialog({ errorMessage = t('Failed to publish to some relays. Please try again or use different relays.') } - alert(errorMessage) + showPublishingError(errorMessage) } finally { setIsSubmitting(false) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 8d5a0ef..97efcfc 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -631,18 +631,29 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const relays = await client.determineTargetRelays(event, options) - 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) + try { + 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 + } catch (error) { + // Even if publishing fails, try to extract relay statuses from the error + if (error instanceof AggregateError && (error as any).relayStatuses) { + (event as any).relayStatuses = (error as any).relayStatuses + console.log('Attached relay statuses from error:', (event as any).relayStatuses) + } + + // Re-throw the error so the UI can handle it appropriately + throw error } - - return event } const attemptDelete = async (targetEvent: Event) => { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index a546e4f..a562c65 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -90,67 +90,23 @@ class ClientService extends EventTarget { { specifiedRelayUrls, additionalRelayUrls }: TPublishOptions = {} ) { let relays: string[] - if (specifiedRelayUrls?.length) { + + // Check if this is a discussion thread or reply to a discussion + const isDiscussionRelated = event.kind === ExtendedKind.DISCUSSION || + event.tags.some(tag => tag[0] === 'k' && tag[1] === String(ExtendedKind.DISCUSSION)) + + // Special handling for discussion-related events: try specified relay first, then fallback + 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 + return relays + } else if (specifiedRelayUrls?.length) { + // For non-discussion events, use specified relays as-is relays = specifiedRelayUrls } else { const _additionalRelayUrls: string[] = additionalRelayUrls ?? [] - // Check if this is a discussion thread or reply to a discussion - const isDiscussionRelated = event.kind === ExtendedKind.DISCUSSION || - event.tags.some(tag => tag[0] === 'k' && tag[1] === String(ExtendedKind.DISCUSSION)) - - // Special handling for kind 11 (DISCUSSION) and kind 1111 (COMMENT/NIP-22) - // These should only be published to the relay where the original event was found - // or to the relay hint specified in the event tags - if (event.kind === ExtendedKind.DISCUSSION || event.kind === ExtendedKind.COMMENT) { - let rootEventId: string | undefined - let relayHint: string | undefined - - if (event.kind === ExtendedKind.COMMENT) { - // Kind 1111 (NIP-22 Comment): look for 'E' tag which points to the root event - // Format: ["E", "", "", ""] - const ETag = event.tags.find(tag => tag[0] === 'E') - if (ETag) { - rootEventId = ETag[1] - relayHint = ETag[2] // Relay hint is the 3rd element - } - - // If no 'E' tag, check lowercase 'e' tag for parent event - // This handles cases where we're replying to a reply - if (!rootEventId) { - const eTag = event.tags.find(tag => tag[0] === 'e') - if (eTag) { - rootEventId = eTag[1] - relayHint = eTag[2] - } - } - } else if (event.kind === ExtendedKind.DISCUSSION) { - // Kind 11 (DISCUSSION): this is the root event itself - // For new root events, we can use the specified relays or fall through to normal handling - // But for replies TO kind 11, we should have caught them as kind 1111 above - rootEventId = event.id - } - - // Priority 1: Use relay hint from the tag if present and valid - if (relayHint && isWebsocketUrl(relayHint)) { - const normalizedRelayHint = normalizeUrl(relayHint) - if (normalizedRelayHint) { - relays = [normalizedRelayHint] - return relays - } - } - - // Priority 2: Get relay where the root event was found - if (rootEventId) { - const originalEventRelays = this.getEventHints(rootEventId) - if (originalEventRelays.length > 0) { - // Only publish to the relay(s) where the original event was found - relays = originalEventRelays.slice(0, 1) // Use only the first relay for insular discussions - return relays - } - } - } - // Publish to mentioned users' inboxes for all events EXCEPT discussions if (!isDiscussionRelated) { const mentions: string[] = [] @@ -208,6 +164,78 @@ class ClientService extends EventTarget { }> successCount: number 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) { + try { + // Try publishing to the relay hint first + const result = await this._publishToRelays(relayUrls, event) + + // If successful, return the result + if (result.success) { + return result + } + + // If failed, try fallback 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 + + console.log('Relay hint failed, trying fallback relays:', fallbackRelays) + const fallbackResult = await this._publishToRelays(fallbackRelays, event) + + // Combine relay statuses from both attempts + const combinedRelayStatuses = [...result.relayStatuses, ...fallbackResult.relayStatuses] + const combinedSuccessCount = combinedRelayStatuses.filter(s => s.success).length + + return { + success: combinedSuccessCount > 0, + relayStatuses: combinedRelayStatuses, + successCount: combinedSuccessCount, + totalCount: combinedRelayStatuses.length + } + } catch (error) { + // If relay hint throws an error, try fallback relays + console.log('Relay hint threw error, trying fallback relays:', error) + + // Extract relay statuses from the error if available + let hintRelayStatuses: any[] = [] + if (error instanceof AggregateError && (error as any).relayStatuses) { + hintRelayStatuses = (error as any).relayStatuses + } + + 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 + + console.log('Trying fallback relays:', fallbackRelays) + const fallbackResult = await this._publishToRelays(fallbackRelays, event) + + // Combine relay statuses from both attempts + const combinedRelayStatuses = [...hintRelayStatuses, ...fallbackResult.relayStatuses] + const combinedSuccessCount = combinedRelayStatuses.filter(s => s.success).length + + return { + success: combinedSuccessCount > 0, + relayStatuses: combinedRelayStatuses, + successCount: combinedSuccessCount, + totalCount: combinedRelayStatuses.length + } + } + } + + // For non-discussion events, use normal publishing + return await this._publishToRelays(relayUrls, event) + } + + private async _publishToRelays(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))) @@ -258,21 +286,22 @@ class ClientService extends EventTarget { }) } else { resolved = true - reject( - new AggregateError( - errors.map( - ({ url, error }) => { - let errorMsg = 'Unknown error' - if (error instanceof Error) { - errorMsg = error.message || 'Empty error message' - } else if (error !== null && error !== undefined) { - errorMsg = String(error) - } - return new Error(`Failed to publish to ${url}: ${errorMsg}`) + const aggregateError = new AggregateError( + errors.map( + ({ url, error }) => { + let errorMsg = 'Unknown error' + if (error instanceof Error) { + errorMsg = error.message || 'Empty error message' + } else if (error !== null && error !== undefined) { + errorMsg = String(error) } - ) + return new Error(`Failed to publish to ${url}: ${errorMsg}`) + } ) ) + // Attach relay statuses to the error so they can be displayed + ;(aggregateError as any).relayStatuses = relayStatuses + reject(aggregateError) } } } @@ -1216,17 +1245,13 @@ class ClientService extends EventTarget { // Normalize relay URLs (remove trailing slashes for consistency) const normalizedUrls = relayUrls.map(url => url.endsWith('/') ? url.slice(0, -1) : url) - console.log(`Trying to fetch from ${normalizedUrls.length} relays:`, normalizedUrls) const events = await this.query(normalizedUrls, filter) - console.log(`Found ${events.length} events from relays`) if (events.length === 0) { - console.log('No events found, continuing to next tier') return undefined } const result = events.sort((a, b) => b.created_at - a.created_at)[0] - console.log('Found event:', result.id) return result } catch (error) { console.error('Error in tryHarderToFetchEvent:', error) diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 3639ce0..f7d7f91 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -34,7 +34,7 @@ class LocalStorageService { private defaultZapSats: number = 21 private defaultZapComment: string = 'Zap!' private quickZap: boolean = false - private zapReplyThreshold: number = 210 + private zapReplyThreshold: number = 2100 private accountFeedInfoMap: Record = {} private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private autoplay: boolean = true