diff --git a/package-lock.json b/package-lock.json
index 199b30f2..d89f34e5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "imwald",
- "version": "23.13.0",
+ "version": "23.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
- "version": "23.13.0",
+ "version": "23.13.1",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
diff --git a/package.json b/package.json
index 1a142f8a..66512a9e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "imwald",
- "version": "23.13.0",
+ "version": "23.13.1",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true,
"type": "module",
diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx
index 4f99a3b8..dcdaed66 100644
--- a/src/components/PaytoDialog/index.tsx
+++ b/src/components/PaytoDialog/index.tsx
@@ -1,3 +1,4 @@
+import TipPublicMessagePrompt from '@/components/ZapDialog/TipPublicMessagePrompt'
import {
Dialog,
DialogContent,
@@ -8,6 +9,7 @@ import {
import { Button } from '@/components/ui/button'
import { Copy, ExternalLink, Wallet, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
+import { useRef, useState } from 'react'
import { toast } from 'sonner'
import {
filterPaytoPaymentOpenHandlersForDevice,
@@ -15,6 +17,7 @@ import {
getPaytoTypeInfo
} from '@/lib/payto'
import { cn } from '@/lib/utils'
+import { useNostr } from '@/providers/NostrProvider'
import LightningInvoiceSection from './LightningInvoiceSection'
export default function PaytoDialog({
@@ -22,15 +25,21 @@ export default function PaytoDialog({
onOpenChange,
type,
authority,
- paytoUri
+ paytoUri,
+ recipientPubkey
}: {
open: boolean
onOpenChange: (open: boolean) => void
type: string
authority: string
paytoUri: string
+ /** When set, closing the dialog offers a kind-24 tip notice to this pubkey. */
+ recipientPubkey?: string
}) {
const { t } = useTranslation()
+ const { pubkey: selfPubkey } = useNostr()
+ const [tipNoticeOpen, setTipNoticeOpen] = useState(false)
+ const skipTipNoticeOnCloseRef = useRef(false)
const info = getPaytoTypeInfo(type)
const label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning'
@@ -41,11 +50,29 @@ export default function PaytoDialog({
const handleCopy = (text: string, copyLabel?: string) => {
navigator.clipboard.writeText(text)
toast.success(copyLabel ? t('Copied {{label}} address', { label: copyLabel }) : t('Copied to clipboard'))
- onOpenChange(false)
+ handleDialogOpenChange(false)
+ }
+
+ const maybeOfferTipNoticeOnClose = () => {
+ if (!recipientPubkey) return
+ if (skipTipNoticeOnCloseRef.current) return
+ if (selfPubkey && recipientPubkey === selfPubkey) return
+ setTipNoticeOpen(true)
+ }
+
+ const handleDialogOpenChange = (next: boolean) => {
+ if (!next) {
+ maybeOfferTipNoticeOnClose()
+ skipTipNoticeOnCloseRef.current = false
+ } else {
+ skipTipNoticeOnCloseRef.current = false
+ }
+ onOpenChange(next)
}
return (
-
- {!paymentsOnly && (
-
- )}
+
>
)
}
diff --git a/src/constants.ts b/src/constants.ts
index 54dcf3b1..cb10fbef 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -425,6 +425,7 @@ export const DOCUMENT_RELAY_URLS = [
*/
export const READ_ONLY_RELAY_URLS = [
'wss://aggr.nostr.land',
+ 'wss://nostr.land',
'wss://relay.nostr.watch',
'wss://relaypag.es',
'wss://relay.noswhere.com',
diff --git a/src/features/feed/relay-policy.test.ts b/src/features/feed/relay-policy.test.ts
index 9ad6b0ea..140e2bb9 100644
--- a/src/features/feed/relay-policy.test.ts
+++ b/src/features/feed/relay-policy.test.ts
@@ -66,4 +66,24 @@ describe('applyFeedRelayPolicy', () => {
})
)
})
+
+ it('excludes profile/index mirrors for kind 1 writes', () => {
+ const result = applyFeedRelayPolicy(
+ [
+ {
+ source: 'viewer-write',
+ urls: ['wss://profiles.nostrver.se/', 'wss://relay.example/']
+ }
+ ],
+ { operation: 'write', eventKind: 1, applySocialKindBlockedFilter: false }
+ )
+
+ expect(result.urls).toEqual(['wss://relay.example/'])
+ expect(result.dropped).toContainEqual(
+ expect.objectContaining({
+ normalizedUrl: 'wss://profiles.nostrver.se/',
+ reason: 'profile-index-for-write'
+ })
+ )
+ })
})
diff --git a/src/features/feed/relay-policy.ts b/src/features/feed/relay-policy.ts
index 50ed3a8a..c0a3d398 100644
--- a/src/features/feed/relay-policy.ts
+++ b/src/features/feed/relay-policy.ts
@@ -3,6 +3,7 @@ import {
SOCIAL_KIND_BLOCKED_RELAY_URLS,
relayFilterIncludesSocialKindBlockedKind
} from '@/constants'
+import { relayAllowsPublishKind } from '@/lib/relay-publish-filter'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { getViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
import {
@@ -19,6 +20,7 @@ export type FeedRelayDropReason =
| 'duplicate'
| 'user-blocked'
| 'read-only-for-write'
+ | 'profile-index-for-write'
| 'social-kind-blocked'
| 'extended-tag-blocked'
| 'third-party-local'
@@ -195,6 +197,14 @@ export function applyFeedRelayPolicy(
addDrop(dropped, normalized, layer.source, 'read-only-for-write')
continue
}
+ if (
+ (context.operation === 'write' || context.operation === 'publish-picker') &&
+ context.eventKind !== undefined &&
+ !relayAllowsPublishKind(normalized, context.eventKind)
+ ) {
+ addDrop(dropped, normalized, layer.source, 'profile-index-for-write')
+ continue
+ }
if (
socialFilter &&
isSocialKindBlockedRelay(key) &&
diff --git a/src/lib/pre-publish-relay-cap.ts b/src/lib/pre-publish-relay-cap.ts
index 3eb02b51..d00f49a0 100644
--- a/src/lib/pre-publish-relay-cap.ts
+++ b/src/lib/pre-publish-relay-cap.ts
@@ -1,9 +1,6 @@
-import {
- isSocialKindBlockedKind,
- MAX_PUBLISH_RELAYS,
- READ_ONLY_RELAY_URLS,
- SOCIAL_KIND_BLOCKED_RELAY_URLS
-} from '@/constants'
+import { isSocialKindBlockedKind, MAX_PUBLISH_RELAYS, SOCIAL_KIND_BLOCKED_RELAY_URLS } from '@/constants'
+import { kinds } from 'nostr-tools'
+import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url'
import type { NostrEvent } from 'nostr-tools'
@@ -49,12 +46,11 @@ export function computePrePublishRelayCapPreview({
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter((u): u is string => !!u)
let outbox = dedupeNormalizeRelayUrlsOrdered([...httpOut, ...wsOut])
- const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
+ const previewKind = kinds.ShortTextNote
const socialBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
outbox = dedupeNormalizeRelayUrlsOrdered(
- outbox.filter((url) => {
+ filterRelaysForEventPublish(outbox, previewKind).filter((url) => {
const n = normalizeAnyRelayUrl(url) || url
- if (readOnlySet.has(n)) return false
if (applySocialOutboxFilter && socialBlockedSet.has(n)) return false
return true
})
diff --git a/src/lib/relay-publish-filter.test.ts b/src/lib/relay-publish-filter.test.ts
new file mode 100644
index 00000000..4a82718f
--- /dev/null
+++ b/src/lib/relay-publish-filter.test.ts
@@ -0,0 +1,41 @@
+import { kinds } from 'nostr-tools'
+import { describe, expect, it } from 'vitest'
+import {
+ filterContextAuthorReadRelaysForPublish,
+ filterRelaysForEventPublish,
+ relayAllowsPublishKind
+} from './relay-publish-filter'
+
+describe('relay-publish-filter', () => {
+ it('blocks profile/index mirrors for kind 1 and 7', () => {
+ expect(relayAllowsPublishKind('wss://profiles.nostr1.com/', kinds.ShortTextNote)).toBe(false)
+ expect(relayAllowsPublishKind('wss://purplepag.es/', kinds.Reaction)).toBe(false)
+ expect(relayAllowsPublishKind('wss://indexer.coracle.social/', kinds.ShortTextNote)).toBe(false)
+ })
+
+ it('allows profile/index mirrors for kind 0 and 10002', () => {
+ expect(relayAllowsPublishKind('wss://profiles.nostrver.se/', kinds.Metadata)).toBe(true)
+ expect(relayAllowsPublishKind('wss://indexer.coracle.social/', kinds.RelayList)).toBe(true)
+ })
+
+ it('strips read-only aggregators and profile mirrors from publish lists', () => {
+ const out = filterRelaysForEventPublish(
+ [
+ 'wss://nostr.land/',
+ 'wss://profiles.nostr1.com/',
+ 'wss://relay.primal.net/',
+ 'wss://aggr.nostr.land/'
+ ],
+ kinds.ShortTextNote
+ )
+ expect(out).toEqual(['wss://relay.primal.net/'])
+ })
+
+ it('strips profile mirrors from author read hints', () => {
+ const out = filterContextAuthorReadRelaysForPublish([
+ 'wss://profiles.nostrver.se/',
+ 'wss://relay.example.com/'
+ ])
+ expect(out).toEqual(['wss://relay.example.com/'])
+ })
+})
diff --git a/src/lib/relay-publish-filter.ts b/src/lib/relay-publish-filter.ts
new file mode 100644
index 00000000..ae79df16
--- /dev/null
+++ b/src/lib/relay-publish-filter.ts
@@ -0,0 +1,65 @@
+import {
+ AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS,
+ READ_ONLY_RELAY_URLS
+} from '@/constants'
+import { normalizeAnyRelayUrl } from '@/lib/url'
+
+/**
+ * Profile mirrors and indexers that reject notes, reactions, and other social kinds.
+ * Distinct from {@link READ_ONLY_RELAY_URLS} (search/index aggregators) and
+ * {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} (subset also listed here for kind 1 / 1111 / 11).
+ */
+export const PROFILE_INDEX_ONLY_RELAY_URLS = [
+ 'wss://profiles.nostr1.com',
+ 'wss://purplepag.es',
+ 'wss://profiles.nostrver.se/',
+ 'wss://indexer.coracle.social/'
+] as const
+
+const profileIndexOnlyKeySet = new Set(
+ PROFILE_INDEX_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean)
+)
+
+const readOnlyKeySet = new Set(
+ READ_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean)
+)
+
+const profileIndexPublishKindSet = new Set(AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS)
+
+function relayKey(url: string): string {
+ return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
+}
+
+export function isProfileIndexOnlyRelay(url: string): boolean {
+ const key = relayKey(url)
+ return key.length > 0 && profileIndexOnlyKeySet.has(key)
+}
+
+export function isReadOnlyRelayUrl(url: string): boolean {
+ const key = relayKey(url)
+ return key.length > 0 && readOnlyKeySet.has(key)
+}
+
+/** True when this relay may receive an EVENT for `eventKind` (profile/list replaceables only on profile mirrors). */
+export function relayAllowsPublishKind(url: string, eventKind: number): boolean {
+ if (!isProfileIndexOnlyRelay(url)) return true
+ return profileIndexPublishKindSet.has(eventKind)
+}
+
+export function filterRelaysForEventPublish(urls: readonly string[], eventKind: number): string[] {
+ return urls.filter((u) => relayAllowsPublishKind(u, eventKind) && !isReadOnlyRelayUrl(u))
+}
+
+/**
+ * Reply/mention author **read** hints used as publish targets: never LAN/Tor, read-only aggregators,
+ * or profile/index mirrors (those are not inboxes for notes or reactions).
+ */
+export function filterContextAuthorReadRelaysForPublish(urls: readonly string[]): string[] {
+ return urls.filter((u) => {
+ const key = relayKey(u)
+ if (!key) return false
+ if (isReadOnlyRelayUrl(u)) return false
+ if (isProfileIndexOnlyRelay(u)) return false
+ return true
+ })
+}
diff --git a/src/lib/relay-url-priority.test.ts b/src/lib/relay-url-priority.test.ts
index 5974d3b4..8bb886cf 100644
--- a/src/lib/relay-url-priority.test.ts
+++ b/src/lib/relay-url-priority.test.ts
@@ -9,12 +9,13 @@ import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize
import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
describe('filterContextAuthorReadRelaysForPublish', () => {
- it('drops loopback, LAN, and .onion; keeps public relays', () => {
+ it('drops loopback, LAN, .onion, and profile/index mirrors; keeps public relays', () => {
const out = filterContextAuthorReadRelaysForPublish([
'ws://localhost:4869/',
'wss://127.0.0.1/',
'wss://192.168.0.5/',
'wss://abcdefghijklmnop.onion/',
+ 'wss://profiles.nostrver.se/',
'wss://relay.example.com/'
])
expect(out).toEqual(['wss://relay.example.com/'])
diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts
index e0bda12e..90fceb6b 100644
--- a/src/lib/relay-url-priority.ts
+++ b/src/lib/relay-url-priority.ts
@@ -21,12 +21,14 @@ export function dedupeNormalizeRelayUrlsOrdered(urls: string[]): string[] {
return out
}
+import { filterContextAuthorReadRelaysForPublish as stripNonInboxPublishHints } from '@/lib/relay-publish-filter'
+
/**
- * NIP-65 **read** (inbox) hints from reply/mention context must never add LAN, loopback, or Tor-only
- * endpoints to the publish list — those are the author's private reachability, not yours.
+ * NIP-65 **read** (inbox) hints from reply/mention context must never add LAN, loopback, Tor-only,
+ * read-only aggregators, or profile/index mirrors to the publish list.
*/
export function filterContextAuthorReadRelaysForPublish(urls: string[]): string[] {
- return dedupeNormalizeRelayUrlsOrdered(urls).filter((u) => {
+ const reachable = dedupeNormalizeRelayUrlsOrdered(urls).filter((u) => {
const n = normalizeAnyRelayUrl(u) || u.trim()
if (!n) return false
if (isLocalNetworkUrl(u) || isLocalNetworkUrl(n)) return false
@@ -38,6 +40,7 @@ export function filterContextAuthorReadRelaysForPublish(urls: string[]): string[
}
return true
})
+ return dedupeNormalizeRelayUrlsOrdered(stripNonInboxPublishHints(reachable))
}
/** LAN / local host relays first, then the rest; deduped. */
@@ -141,7 +144,7 @@ function buildWriteRelayPriorityLayers(opts: {
authorReadRelays?: string[]
favoriteRelays?: string[]
extraRelays?: string[]
- /** When false, omit global FAST_WRITE and FAST_READ tails. Default true. */
+ /** When false, omit global FAST_WRITE tail. Default true. */
includeGlobalFastWriteReadTails?: boolean
}): string[][] {
const tier1 = relayUrlsLocalsFirst(opts.userWriteRelays)
@@ -149,15 +152,15 @@ function buildWriteRelayPriorityLayers(opts: {
const tier3 = dedupeNormalizeRelayUrlsOrdered(opts.favoriteRelays ?? [])
const tier4 = dedupeNormalizeRelayUrlsOrdered(opts.extraRelays ?? [])
if (opts.includeGlobalFastWriteReadTails === false) {
- return [tier1, tier2, tier3, tier4, [], []]
+ return [tier1, tier2, tier3, tier4, []]
}
const tier5 = normFastWrite()
- const tier6 = normFastRead()
- return [tier1, tier2, tier3, tier4, tier5, tier6]
+ return [tier1, tier2, tier3, tier4, tier5]
}
/**
- * Publish / write: user outboxes (locals first) → target author inboxes → favorites → extras → FAST_WRITE → FAST_READ.
+ * Publish / write: user outboxes (locals first) → target author inboxes → favorites → extras → FAST_WRITE.
+ * Read aggregators ({@link FAST_READ_RELAY_URLS}) are intentionally omitted — they reject social writes.
*/
export function buildPrioritizedWriteRelayUrls(opts: {
userWriteRelays: string[]
@@ -168,7 +171,7 @@ export function buildPrioritizedWriteRelayUrls(opts: {
maxRelays?: number
/** When true, strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before capping (social kinds). */
applySocialKindBlockedFilter?: boolean
- /** Default true: append FAST_WRITE then FAST_READ tiers. */
+ /** Default true: append FAST_WRITE tier. */
includeGlobalFastWriteReadTails?: boolean
}): string[] {
const max = opts.maxRelays ?? MAX_PUBLISH_RELAYS
@@ -184,8 +187,7 @@ export function buildPrioritizedWriteRelayUrls(opts: {
{ source: 'author-read', urls: layers[1] ?? [] },
{ source: 'favorites', urls: layers[2] ?? [] },
{ source: 'explicit', urls: layers[3] ?? [] },
- { source: 'fast-write', urls: layers[4] ?? [] },
- { source: 'fast-read', urls: layers[5] ?? [] }
+ { source: 'fast-write', urls: layers[4] ?? [] }
], {
operation: 'write',
blockedRelays: opts.blockedRelays,
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 7d3436ce..7fd138d3 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -129,6 +129,7 @@ import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/
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 {
buildPrioritizedWriteRelayUrls,
dedupeNormalizeRelayUrlsOrdered,
@@ -710,12 +711,10 @@ class ClientService extends EventTarget {
* Normalize, dedupe, then cap at {@link MAX_PUBLISH_RELAYS}.
*/
private filterPublishingRelays(relays: string[], event: NEvent): string[] {
- const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
return dedupeNormalizeRelayUrlsOrdered(
- relays.filter((url) => {
+ filterRelaysForEventPublish(relays, event.kind).filter((url) => {
const n = normalizeAnyRelayUrl(url) || url
- if (readOnlySet.has(n)) return false
if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false
return true
})
@@ -1589,11 +1588,9 @@ class ClientService extends EventTarget {
: relayUrls
}
- const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
- let filtered = mergedRelayUrls.filter((url) => {
+ let filtered = filterRelaysForEventPublish(mergedRelayUrls, event.kind).filter((url) => {
const n = normalizeAnyRelayUrl(url) || url
- if (readOnlySet.has(n)) return false
if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false
return true
})
diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts
index 0d9dcba4..8e652828 100644
--- a/src/services/relay-selection.service.ts
+++ b/src/services/relay-selection.service.ts
@@ -1,5 +1,6 @@
import { Event, kinds } from 'nostr-tools'
-import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT, READ_ONLY_RELAY_URLS } from '@/constants'
+import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants'
+import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter'
import storage from '@/services/local-storage.service'
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
import client from '@/services/client.service'
@@ -176,7 +177,7 @@ class RelaySelectionService {
}
const deduplicatedRelays = order.map((o) => o.url)
- const filtered = this.filterReadOnlyRelays(
+ const filtered = this.filterPublishPickerRelays(
this.filterBlockedRelays(deduplicatedRelays, context.blockedRelays)
)
const relayTypes: Record = {}
@@ -186,7 +187,7 @@ class RelaySelectionService {
return {
relays: filtered,
relayTypes,
- randomRelayUrls: this.filterReadOnlyRelays(randomRelayUrls)
+ randomRelayUrls: this.filterPublishPickerRelays(randomRelayUrls)
}
}
@@ -438,7 +439,7 @@ class RelaySelectionService {
selectedRelays = Array.from(new Set(selectedRelays))
}
- return this.filterReadOnlyRelays(this.filterBlockedRelays(selectedRelays, context.blockedRelays))
+ return this.filterPublishPickerRelays(this.filterBlockedRelays(selectedRelays, context.blockedRelays))
}
/**
@@ -808,17 +809,11 @@ class RelaySelectionService {
}
/**
- * Strip relays that never accept writes ({@link READ_ONLY_RELAY_URLS}) so they do not appear in the publish picker.
- * Same set as `ClientService` uses when filtering publish targets.
+ * Strip read-only aggregators and profile/index mirrors from the post/reaction publish picker
+ * (notes and reactions are not kind 0 / NIP-65 list traffic).
*/
- private filterReadOnlyRelays(relays: string[]): string[] {
- const readOnlySet = new Set(
- READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
- )
- return relays.filter((relay) => {
- const n = normalizeAnyRelayUrl(relay) || relay
- return !readOnlySet.has(n)
- })
+ private filterPublishPickerRelays(relays: string[]): string[] {
+ return filterRelaysForEventPublish(relays, kinds.ShortTextNote)
}
/**