diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx
index 38d731ec..716d3b1c 100644
--- a/src/components/Profile/ProfileFeedWithPins.tsx
+++ b/src/components/Profile/ProfileFeedWithPins.tsx
@@ -221,10 +221,12 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
return () => observer.disconnect()
}, [totalVisible, mergedDisplay.length])
- const loading =
- (loadingPins || loadingTimeline || loadingZapPollVotes) && mergedDisplay.length === 0
+ // Pins and zap-poll votes can take longer than the timeline; do not block the whole tab on them.
+ // Show posts as soon as the timeline has delivered anything (or finished empty).
+ const showFullSkeleton =
+ mergedDisplay.length === 0 && loadingTimeline && timelineEvents.length === 0
- if (loading) {
+ if (showFullSkeleton) {
return (
@@ -301,6 +303,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
))}
)}
+ {mergedDisplay.length === 0 && (loadingPins || loadingZapPollVotes) && (
+
+ {t('Loading…')}
+
+ )}
{displayedPins.length > 0 && displayedFeed.length > 0 && (
{t('Feed')}
diff --git a/src/components/Profile/ProfileMediaFeed.tsx b/src/components/Profile/ProfileMediaFeed.tsx
index c835e74e..cfc88f48 100644
--- a/src/components/Profile/ProfileMediaFeed.tsx
+++ b/src/components/Profile/ProfileMediaFeed.tsx
@@ -1,12 +1,13 @@
import NoteList, { type TNoteListRef } from '@/components/NoteList'
import { buildAuthorInboxOutboxRelayUrls } from '@/lib/favorites-feed-relays'
import logger from '@/lib/logger'
-import { normalizeHexPubkey } from '@/lib/pubkey'
import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity'
import { PROFILE_MEDIA_TAB_KINDS } from '@/constants'
import { buildProfileMediaSubRequests } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import { useNostrOptional } from '@/providers/nostr-context'
+import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import client from '@/services/client.service'
import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -19,8 +20,19 @@ const MEDIA_LOG = '[ProfileMedia]'
const ProfileMediaFeed = forwardRef(({ pubkey }, ref) => {
const { t } = useTranslation()
+ const nostr = useNostrOptional()
const { blockedRelays } = useFavoriteRelays()
const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays])
+ const includeAuthorLocalRelays = useMemo(() => {
+ const me = nostr?.pubkey?.trim()
+ const pk = pubkey?.trim()
+ if (!me || !pk) return false
+ try {
+ return hexPubkeysEqual(normalizeHexPubkey(me), normalizeHexPubkey(pk))
+ } catch {
+ return false
+ }
+ }, [nostr?.pubkey, pubkey])
/**
* Before NIP-65: empty author tier so REQ still uses read-only + fast-read; refine when
@@ -28,8 +40,8 @@ const ProfileMediaFeed = forwardRef(({ pubkey
*/
const provisionalAuthorRelayUrls = useMemo(() => {
if (!pubkey?.trim()) return [] as string[]
- return buildAuthorInboxOutboxRelayUrls({ read: [], write: [] }, blockedRelays)
- }, [pubkey, blockedKey, blockedRelays])
+ return buildAuthorInboxOutboxRelayUrls({ read: [], write: [] }, blockedRelays, includeAuthorLocalRelays)
+ }, [pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays])
const [refinedAuthorRelayUrls, setRefinedAuthorRelayUrls] = useState(null)
@@ -48,7 +60,7 @@ const ProfileMediaFeed = forwardRef(({ pubkey
write: [] as string[]
}))
if (cancelled) return
- const authorStack = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays)
+ const authorStack = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays)
const hexPk = normalizeHexPubkey(pk)
logger.debug(`${MEDIA_LOG} NIP-65 author relays resolved for media tab`, {
pubkey: hexPk.slice(0, 8),
@@ -63,7 +75,7 @@ const ProfileMediaFeed = forwardRef(({ pubkey
return () => {
cancelled = true
}
- }, [pubkey, blockedKey, blockedRelays])
+ }, [pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays])
const authorRelayUrls = refinedAuthorRelayUrls ?? provisionalAuthorRelayUrls
diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx
index fb5ba45f..2191d05b 100644
--- a/src/hooks/useProfilePins.tsx
+++ b/src/hooks/useProfilePins.tsx
@@ -8,9 +8,10 @@ import {
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
} from '@/constants'
-import { normalizeHexPubkey } from '@/lib/pubkey'
+import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import { useNostrOptional } from '@/providers/nostr-context'
import client, { eventService, queryService } from '@/services/client.service'
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
@@ -75,8 +76,19 @@ function blockedRelaysContentKey(blockedRelays: string[]): string {
}
export function useProfilePins(pubkey: string | undefined) {
+ const nostr = useNostrOptional()
const { blockedRelays } = useFavoriteRelays()
const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays])
+ const includeAuthorLocalRelays = useMemo(() => {
+ const me = nostr?.pubkey?.trim()
+ const pk = pubkey?.trim()
+ if (!me || !pk) return false
+ try {
+ return hexPubkeysEqual(normalizeHexPubkey(me), normalizeHexPubkey(pk))
+ } catch {
+ return false
+ }
+ }, [nostr?.pubkey, pubkey])
const [pinEvents, setPinEvents] = useState([])
const [loadingPins, setLoadingPins] = useState(false)
@@ -132,7 +144,7 @@ export function useProfilePins(pubkey: string | undefined) {
})),
client.fetchPinListEvent(pk).catch(() => undefined)
])
- const authorRelays = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays)
+ const authorRelays = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays)
const pinsResolveRelays = buildProfileAugmentedReadRelayUrls(authorRelays, blockedRelays)
if (!pinsResolveRelays.length) {
setPinEvents([])
@@ -237,7 +249,7 @@ export function useProfilePins(pubkey: string | undefined) {
setLoadingPins(false)
}
},
- [pubkey, blockedKey, blockedRelays]
+ [pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays]
)
useEffect(() => {
diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx
index f128ec47..3418ff15 100644
--- a/src/hooks/useProfileTimeline.tsx
+++ b/src/hooks/useProfileTimeline.tsx
@@ -4,8 +4,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event } from 'nostr-tools'
import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
+import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import { useNostrOptional } from '@/providers/nostr-context'
type ProfileTimelineMemoryEntry = {
events: Event[]
@@ -124,7 +126,17 @@ export function useProfileTimeline({
limit = 200,
filterPredicate
}: UseProfileTimelineOptions): UseProfileTimelineResult {
+ const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
+ const includeAuthorLocalRelays = useMemo(() => {
+ const me = nostr?.pubkey?.trim()
+ if (!me) return false
+ try {
+ return hexPubkeysEqual(normalizeHexPubkey(me), normalizeHexPubkey(pubkey))
+ } catch {
+ return false
+ }
+ }, [nostr?.pubkey, pubkey])
const relayListsKey = useMemo(
() => relayListsContentKey(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays]
@@ -208,7 +220,8 @@ export function useProfileTimeline({
favoriteRelays,
blockedRelays,
emptyAuthor,
- socialKinds
+ socialKinds,
+ includeAuthorLocalRelays
)
const startWave = async (subRequests: ReturnType) => {
@@ -259,7 +272,8 @@ export function useProfileTimeline({
favoriteRelays,
blockedRelays,
authorRl,
- socialKinds
+ socialKinds,
+ includeAuthorLocalRelays
)
const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls)
if (cancelled || deltaUrls.length === 0) return
@@ -274,7 +288,7 @@ export function useProfileTimeline({
subscriptionRef.current()
subscriptionRef.current = () => {}
}
- }, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey])
+ }, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey, includeAuthorLocalRelays])
const refresh = useCallback(() => {
subscriptionRef.current()
diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts
index 8702726c..955693bc 100644
--- a/src/lib/favorites-feed-relays.ts
+++ b/src/lib/favorites-feed-relays.ts
@@ -14,6 +14,7 @@ import {
mergeRelayPriorityLayers,
relayUrlsLocalsFirst
} from '@/lib/relay-url-priority'
+import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
const blockedSet = (blockedRelays: string[]) =>
new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b))
@@ -77,19 +78,19 @@ export function mergeRelayUrlLayers(layers: string[][], blockedRelays: string[])
/**
* Viewed author’s NIP-65 read list (inboxes), then write list (outboxes), each with LAN/local URLs first; blocked
* stripped. Used for profile pins + Medien before {@link buildProfileAugmentedReadRelayUrls}.
+ *
+ * @param includeAuthorLocalRelays When true (viewing your own profile), keep LAN hints so local cache/outbox works.
*/
export function buildAuthorInboxOutboxRelayUrls(
authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
- blockedRelays: string[]
+ blockedRelays: string[],
+ includeAuthorLocalRelays = false
): string[] {
- const inboxLayer = relayUrlsLocalsFirst([
- ...(authorRelayList.httpRead ?? []),
- ...(authorRelayList.read ?? [])
- ])
- const outboxLayer = relayUrlsLocalsFirst([
- ...(authorRelayList.httpWrite ?? []),
- ...(authorRelayList.write ?? [])
- ])
+ const list = includeAuthorLocalRelays
+ ? authorRelayList
+ : stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
+ const inboxLayer = relayUrlsLocalsFirst([...(list.httpRead ?? []), ...(list.read ?? [])])
+ const outboxLayer = relayUrlsLocalsFirst([...(list.httpWrite ?? []), ...(list.write ?? [])])
return mergeRelayUrlLayers([inboxLayer, outboxLayer], blockedRelays)
}
@@ -159,7 +160,8 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
* Profile page pins + feed: viewed author's NIP-65 read + write (REQ tier 1), then logged-in user's favorites,
* then fast-read defaults from constants, deduped and blocked-stripped, capped at this count.
*/
-const PROFILE_PAGE_FEED_MAX_RELAYS = 6
+/** Profile REQ cap: too small waits on a few bad relays; larger spreads load across fast-read / favorites. */
+const PROFILE_PAGE_FEED_MAX_RELAYS = 14
export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10
@@ -167,14 +169,18 @@ export function buildProfilePageReadRelayUrls(
favoriteRelays: string[],
blockedRelays: string[],
authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
- kindsIncludeSocialBlockedKind: boolean
+ kindsIncludeSocialBlockedKind: boolean,
+ includeAuthorLocalRelays = false
): string[] {
+ const list = includeAuthorLocalRelays
+ ? authorRelayList
+ : stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
return getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
- [...(authorRelayList.httpRead ?? []), ...(authorRelayList.read ?? [])],
+ [...(list.httpRead ?? []), ...(list.read ?? [])],
{
- userWriteRelays: [...(authorRelayList.httpWrite ?? []), ...(authorRelayList.write ?? [])],
+ userWriteRelays: [...(list.httpWrite ?? []), ...(list.write ?? [])],
authorWriteRelays: [],
maxRelays: PROFILE_PAGE_FEED_MAX_RELAYS,
applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind
diff --git a/src/lib/relay-list-sanitize.ts b/src/lib/relay-list-sanitize.ts
index 6df617e0..5f34a81a 100644
--- a/src/lib/relay-list-sanitize.ts
+++ b/src/lib/relay-list-sanitize.ts
@@ -1,6 +1,35 @@
import { isHttpRelayUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import type { TRelayList } from '@/types'
+/** True if this URL is not loopback / LAN (safe to open from another user's browser as a REQ target). */
+export function urlIsNonLocalForRemoteViewer(url: string): boolean {
+ const t = typeof url === 'string' ? url.trim() : ''
+ if (!t) return false
+ if (isLocalNetworkUrl(t)) return false
+ const n = normalizeAnyRelayUrl(t) || ''
+ if (n && isLocalNetworkUrl(n)) return false
+ return true
+}
+
+/**
+ * Drop LAN/loopback from NIP-65 + HTTP mailbox fields when resolving **another** author's data:
+ * the viewer cannot reach the author's `localhost` / `192.168.*` / etc., but we used to rank them first.
+ */
+export function stripMailboxLocalUrlsForRemoteViewers(list: {
+ read: string[]
+ write: string[]
+ httpRead?: string[]
+ httpWrite?: string[]
+}): { read: string[]; write: string[]; httpRead: string[]; httpWrite: string[] } {
+ const f = (arr: string[] | undefined) => (arr ?? []).filter(urlIsNonLocalForRemoteViewer)
+ return {
+ read: f(list.read),
+ write: f(list.write),
+ httpRead: f(list.httpRead),
+ httpWrite: f(list.httpWrite)
+ }
+}
+
/**
* Remove LAN / loopback relay URLs (e.g. ws://localhost:4869, 192.168.x.x).
* Apply to **kind 10002** (NIP-65): those URLs belong on kind 10432 (cache relays), not read/write outbox/inbox.
diff --git a/src/lib/relay-url-priority.test.ts b/src/lib/relay-url-priority.test.ts
index ea7f7e73..692ec598 100644
--- a/src/lib/relay-url-priority.test.ts
+++ b/src/lib/relay-url-priority.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { dedupeNormalizeRelayUrlsOrdered, filterContextAuthorReadRelaysForPublish } from '@/lib/relay-url-priority'
+import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
describe('filterContextAuthorReadRelaysForPublish', () => {
it('drops loopback, LAN, and .onion; keeps public relays', () => {
@@ -23,3 +24,18 @@ describe('filterContextAuthorReadRelaysForPublish', () => {
expect(b).toEqual(a)
})
})
+
+describe('stripMailboxLocalUrlsForRemoteViewers', () => {
+ it('removes loopback and LAN from read/write/http fields', () => {
+ const out = stripMailboxLocalUrlsForRemoteViewers({
+ read: ['ws://localhost:4869/', 'wss://relay.example.com/'],
+ write: ['wss://192.168.1.1/', 'wss://author-outbox.example/'],
+ httpRead: ['http://127.0.0.1:8080/'],
+ httpWrite: []
+ })
+ expect(out.read).toEqual(['wss://relay.example.com/'])
+ expect(out.write).toEqual(['wss://author-outbox.example/'])
+ expect(out.httpRead).toEqual([])
+ expect(out.httpWrite).toEqual([])
+ })
+})
diff --git a/src/services/media-upload.service.ts b/src/services/media-upload.service.ts
index 8c6e4c76..1748d8b3 100644
--- a/src/services/media-upload.service.ts
+++ b/src/services/media-upload.service.ts
@@ -12,8 +12,9 @@ import { simplifyUrl } from '@/lib/url'
import { TDraftEvent, TMediaUploadServiceConfig } from '@/types'
import { BlossomClient } from 'blossom-client-sdk'
import { z } from 'zod'
-import client from './client.service'
+/** Must run before `./client.service` — that graph can synchronously re-enter this module; `storage` must be bound first (constructor reads it at module bottom). */
import storage from './local-storage.service'
+import client from './client.service'
type UploadOptions = {
onProgress?: (progressPercent: number) => void