diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index e10fd142..5448de7e 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -1341,7 +1341,11 @@ export default function PostContent({ specifiedRelayUrls: relayUrls, additionalRelayUrls: isPoll ? pollCreateData.relays : (isPrivateEvent ? privateRelayUrls : additionalRelayUrls), minPow, - disableFallbacks: additionalRelayUrls.length > 0 || isPrivateEvent, // Don't use fallbacks if user explicitly selected relays or for private events + disableFallbacks: + additionalRelayUrls.length > 0 || + isPrivateEvent || + isPublicMessage || + parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE, addClientTag }) // console.log('Published event:', newEvent) diff --git a/src/lib/public-message-publish-relays.test.ts b/src/lib/public-message-publish-relays.test.ts new file mode 100644 index 00000000..cebc657a --- /dev/null +++ b/src/lib/public-message-publish-relays.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + buildPublicMessagePublishRelayUrls, + collectRecipientInboxUrls, + collectSenderOutboxUrls +} from './public-message-publish-relays' + +describe('public-message-publish-relays', () => { + it('collects sender outbox and recipient inbox only', () => { + expect( + collectSenderOutboxUrls({ + write: ['wss://sender-out.example/'], + read: ['wss://sender-in.example/'], + httpWrite: ['https://sender-http.example/'], + httpRead: ['https://sender-http-in.example/'], + originalRelays: [] + }) + ).toEqual(['https://sender-http.example/', 'wss://sender-out.example/']) + + expect( + collectRecipientInboxUrls({ + write: ['wss://recipient-out.example/'], + read: ['wss://recipient-in.example/'], + httpWrite: [], + httpRead: ['https://recipient-http-in.example/'], + originalRelays: [] + }) + ).toEqual(['https://recipient-http-in.example/', 'wss://recipient-in.example/']) + }) + + it('orders author outbox before recipient inbox', () => { + const urls = buildPublicMessagePublishRelayUrls( + ['wss://sender-out.example/'], + ['wss://recipient-in.example/'] + ) + expect(urls).toEqual(['wss://sender-out.example/', 'wss://recipient-in.example/']) + }) +}) diff --git a/src/lib/public-message-publish-relays.ts b/src/lib/public-message-publish-relays.ts new file mode 100644 index 00000000..dde6c404 --- /dev/null +++ b/src/lib/public-message-publish-relays.ts @@ -0,0 +1,70 @@ +import { + PUBLIC_MESSAGE_RSVP_PUBLISH_AUTHOR_WRITE_CAP, + PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS +} from '@/constants' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' +import { dedupeNormalizeRelayUrlsOrdered, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' +import { isLocalNetworkUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' +import type { TRelayList } from '@/types' + +/** NIP-65 / 10243 outbox URLs for the sender (includes viewer-local outboxes). */ +export function collectSenderOutboxUrls( + relayList: TRelayList | null | undefined, + extraWriteUrls: readonly string[] = [] +): string[] { + const http = (relayList?.httpWrite ?? []) + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter((u): u is string => !!u) + const ws = (relayList?.write ?? []) + .map((u) => normalizeUrl(u) || u) + .filter((u): u is string => !!u) + return dedupeNormalizeRelayUrlsOrdered([...http, ...ws, ...extraWriteUrls]) +} + +/** NIP-65 / 10243 inbox URLs for a recipient (drops other people's LAN/loopback). */ +export function collectRecipientInboxUrls(relayList: TRelayList | null | undefined): string[] { + const http = (relayList?.httpRead ?? []) + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter((u): u is string => !!u && !isLocalNetworkUrl(u)) + const ws = (relayList?.read ?? []) + .map((u) => normalizeUrl(u) || u) + .filter((u): u is string => !!u && !isLocalNetworkUrl(u)) + return dedupeNormalizeRelayUrlsOrdered([...http, ...ws]) +} + +/** + * Kind 24 / 31925 publish stack: sender outboxes first (capped), then recipient inboxes, then remaining outboxes. + * No FAST_WRITE, favorites, or read aggregators. + */ +export function buildPublicMessagePublishRelayUrls( + senderOutbox: readonly string[], + recipientInbox: readonly string[], + options?: { blockedRelays?: readonly string[]; maxRelays?: number } +): string[] { + const authorWriteOrdered = relayUrlsLocalsFirst([...senderOutbox]) + const recipientReadDeduped = dedupeNormalizeRelayUrlsOrdered([...recipientInbox]) + const authorTier1Cap = + recipientReadDeduped.length > 0 + ? Math.min(PUBLIC_MESSAGE_RSVP_PUBLISH_AUTHOR_WRITE_CAP, authorWriteOrdered.length) + : authorWriteOrdered.length + const authorPrimary = authorWriteOrdered.slice(0, authorTier1Cap) + const authorOverflow = authorWriteOrdered.slice(authorTier1Cap) + const publishCap = + recipientReadDeduped.length > 0 ? PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS : MAX_PUBLISH_RELAYS + + return feedRelayPolicyUrls( + [ + { source: 'viewer-write', urls: authorPrimary }, + { source: 'author-read', urls: recipientReadDeduped }, + { source: 'viewer-write', urls: authorOverflow } + ], + { + operation: 'write', + blockedRelays: options?.blockedRelays, + maxRelays: options?.maxRelays ?? publishCap, + nostrLandAggr: 'never', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + } + ) +} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 7fd138d3..76242839 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -16,8 +16,6 @@ import { SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_PUBLISH_RELAYS, - PUBLIC_MESSAGE_RSVP_PUBLISH_AUTHOR_WRITE_CAP, - PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS, PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS, FETCH_RELAY_LIST_UI_TIMEOUT_MS, @@ -130,11 +128,15 @@ import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' +import { + buildPublicMessagePublishRelayUrls, + collectRecipientInboxUrls, + collectSenderOutboxUrls +} from '@/lib/public-message-publish-relays' import { buildPrioritizedWriteRelayUrls, dedupeNormalizeRelayUrlsOrdered, - filterContextAuthorReadRelaysForPublish, - relayUrlsLocalsFirst + filterContextAuthorReadRelaysForPublish } from '@/lib/relay-url-priority' import { IndexRelayTransportError, @@ -162,7 +164,6 @@ import { simplifyUrl } from '@/lib/url' import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor' -import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { initRelayPoolIdle, touchRelayPoolActivity } from '@/lib/relay-pool-idle' import { relaySessionStrikes } from '@/lib/relay-strikes' import { isSafari } from '@/lib/utils' @@ -1191,44 +1192,12 @@ class ClientService extends EventTarget { this.fetchRelayListWithPublishTimeout(event.pubkey), recipientListsPromise ]) - const authorHttpWrites = (authorRelayList?.httpWrite ?? []) - .map((url) => normalizeHttpRelayUrl(url)) - .filter((url): url is string => !!url) - const authorWsWrites = (authorRelayList?.write ?? []) - .map((url) => normalizeUrl(url)) - .filter((url): url is string => !!url) - let authorWrite = dedupeNormalizeRelayUrlsOrdered([...authorHttpWrites, ...authorWsWrites]) - if (authorWrite.length === 0) { - authorWrite = useGlobalRelayDefaults ? [...FAST_WRITE_RELAY_URLS] : [] - } - let recipientRead: string[] = [] - recipientRead = recipientRelayLists.flatMap((rl) => [ - ...(rl?.httpRead ?? []).map((url) => normalizeHttpRelayUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)), - ...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)) - ]) - recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead) - const authorWriteOrdered = relayUrlsLocalsFirst(authorWrite) - /** Without this, tier‑1 author outboxes can consume all of {@link MAX_PUBLISH_RELAYS} and organizer inboxes never receive RSVPs. */ - const recipientReadDeduped = recipientRead - const authorTier1Cap = - recipientReadDeduped.length > 0 - ? Math.min(PUBLIC_MESSAGE_RSVP_PUBLISH_AUTHOR_WRITE_CAP, authorWriteOrdered.length) - : authorWriteOrdered.length - const authorPrimary = authorWriteOrdered.slice(0, authorTier1Cap) - const authorOverflow = authorWriteOrdered.slice(authorTier1Cap) - const publishCap = - recipientReadDeduped.length > 0 ? PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS : MAX_PUBLISH_RELAYS - let pubRelays = feedRelayPolicyUrls([ - { source: 'viewer-write', urls: authorPrimary }, - { source: 'author-read', urls: recipientReadDeduped }, - { source: 'viewer-write', urls: authorOverflow } - ], { - operation: 'write', - blockedRelays: blockedRelayUrls, - maxRelays: publishCap, - nostrLandAggr: 'never', - applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true + const authorWrite = collectSenderOutboxUrls(authorRelayList) + const recipientRead = dedupeNormalizeRelayUrlsOrdered( + recipientRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl)) + ) + let pubRelays = buildPublicMessagePublishRelayUrls(authorWrite, recipientRead, { + blockedRelays: blockedRelayUrls }) pubRelays = this.filterPublishingRelays(pubRelays, event) logger.debug('[DetermineTargetRelays] Public message / calendar RSVP: author outbox + recipient inboxes only', { @@ -1237,21 +1206,7 @@ class ClientService extends EventTarget { authorWriteCount: authorWrite.length, recipientReadCount: recipientRead.length }) - if (pubRelays.length > 0) return pubRelays - if (!useGlobalRelayDefaults) { - return this.filterPublishingRelays([], event) - } - return this.filterPublishingRelays( - feedRelayPolicyUrls([{ source: 'fast-write', urls: relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS]) }], { - operation: 'write', - blockedRelays: blockedRelayUrls, - maxRelays: MAX_PUBLISH_RELAYS, - nostrLandAggr: 'never', - applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true - }), - event - ) + return pubRelays } let relays: string[] diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index 8e652828..f98c058c 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -1,6 +1,10 @@ import { Event, kinds } from 'nostr-tools' import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants' import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' +import { + collectRecipientInboxUrls, + collectSenderOutboxUrls +} from '@/lib/public-message-publish-relays' import storage from '@/services/local-storage.service' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import client from '@/services/client.service' @@ -101,6 +105,7 @@ class RelaySelectionService { ): Promise<{ relays: string[]; relayTypes: Record; randomRelayUrls: string[] }> { const { userWriteRelays, + userHttpWriteRelays, favoriteRelays, relaySets, parentEvent, @@ -108,6 +113,24 @@ class RelaySelectionService { openFrom } = context + if ( + isPublicMessage || + (parentEvent != null && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) + ) { + const pmRelays = await this.getPublicMessageRelays(context) + const filtered = this.filterPublishPickerRelays( + this.filterBlockedRelays(pmRelays, context.blockedRelays) + ) + const relayTypes: Record = {} + const httpSet = new Set( + (userHttpWriteRelays ?? []).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) + ) + filtered.forEach((url) => { + relayTypes[url] = httpSet.has(url) ? 'http_relay_list' : 'relay_list' + }) + return { relays: filtered, relayTypes, randomRelayUrls: [] } + } + const order: { url: string; type: RelaySourceType }[] = [] const seen = new Set() @@ -385,9 +408,10 @@ class RelaySelectionService { else if (parentEvent && (parentEvent.kind === ExtendedKind.DISCUSSION || parentEvent.kind === ExtendedKind.COMMENT)) { selectedRelays = await this.getDiscussionReplyRelays(context) } - // For public messages, use sender outboxes + receiver inboxes + // For public messages, use sender outboxes + receiver inboxes only else if (isPublicMessage || (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE)) { selectedRelays = await this.getPublicMessageRelays(context) + return this.filterPublishPickerRelays(this.filterBlockedRelays(selectedRelays, context.blockedRelays)) } // For regular replies, use user's write relays + mention relays else if (parentEvent && this.isRegularReply(parentEvent)) { @@ -456,28 +480,22 @@ class RelaySelectionService { const allMembers = new Set() try { - // Get sender's outboxes (write relays) + // Get sender's outboxes (write + HTTP write relays) if (userPubkey) { allMembers.add(userPubkey) - let senderRelays = userWriteRelays - - // If userWriteRelays is empty, try to fetch the user's relay list + let senderRelays = collectSenderOutboxUrls( + null, + [...(context.userHttpWriteRelays ?? []), ...userWriteRelays] + ) if (senderRelays.length === 0) { try { const userRelayList = await this.getCachedRelayList(userPubkey) - if (userRelayList?.write && userRelayList.write.length > 0) { - senderRelays = userRelayList.write - } else { - // Only fall back to fast write relays if we truly have no user relays - senderRelays = FAST_WRITE_RELAY_URLS - } + senderRelays = collectSenderOutboxUrls(userRelayList) } catch (error) { logger.warn('Failed to fetch user relay list for PM', { error, userPubkey }) - // Fall back to fast write relays if fetch fails - senderRelays = FAST_WRITE_RELAY_URLS } } - + senderRelays.forEach(url => { const normalized = normalizeAnyRelayUrl(url) if (normalized) { @@ -523,9 +541,7 @@ class RelaySelectionService { // Use cached version from IndexedDB const relayList = await this.getCachedRelayList(pubkey) if (!relayList) return [] - const userRelays = relayList.read || [] - // Filter out local relays from other users - return this.filterLocalRelaysFromOthers(userRelays) + return this.filterLocalRelaysFromOthers(collectRecipientInboxUrls(relayList)) } catch (error) { logger.warn('Failed to fetch relay list', { pubkey, error }) return [] @@ -609,9 +625,10 @@ class RelaySelectionService { return Array.from(new Set(normalizedRelays)) } catch (error) { logger.error('Failed to get public message relays', { error, parentEvent: context.parentEvent?.id }) - // Fallback to sender's write relays - const senderRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS - return senderRelays.map(url => normalizeAnyRelayUrl(url) || url).filter(Boolean) + return collectSenderOutboxUrls(null, [ + ...(context.userHttpWriteRelays ?? []), + ...userWriteRelays + ]) } }