From 74e1d4d77f25692abc653d6562f99b023e785206 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 18 Apr 2026 22:34:48 +0200 Subject: [PATCH] bug-fix --- .../Profile/ProfileFeedWithPins.tsx | 13 ++++++-- src/components/Profile/ProfileMediaFeed.tsx | 22 ++++++++++--- src/hooks/useProfilePins.tsx | 18 +++++++++-- src/hooks/useProfileTimeline.tsx | 20 ++++++++++-- src/lib/favorites-feed-relays.ts | 32 +++++++++++-------- src/lib/relay-list-sanitize.ts | 29 +++++++++++++++++ src/lib/relay-url-priority.test.ts | 16 ++++++++++ src/services/media-upload.service.ts | 3 +- 8 files changed, 125 insertions(+), 28 deletions(-) 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