diff --git a/src/components/HttpRelaysSetting/index.tsx b/src/components/HttpRelaysSetting/index.tsx
index dc6d30cd..b54a142f 100644
--- a/src/components/HttpRelaysSetting/index.tsx
+++ b/src/components/HttpRelaysSetting/index.tsx
@@ -25,7 +25,6 @@ import MailboxRelay from '../MailboxSetting/MailboxRelay'
import NewMailboxRelayInput from '../MailboxSetting/NewMailboxRelayInput'
import RelayCountWarning from '../MailboxSetting/RelayCountWarning'
import SaveButton from './SaveButton'
-import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays'
export default function HttpRelaysSetting() {
const { t } = useTranslation()
@@ -120,15 +119,6 @@ export default function HttpRelaysSetting() {
return null
}
- const handleAddDiscovered = (newRelays: TMailboxRelay[]) => {
- const httpOnly = newRelays.filter((r) => isHttpRelayUrl(r.url))
- const toAdd = httpOnly.filter((nr) => !relays.some((r) => r.url === nr.url))
- if (toAdd.length > 0) {
- setRelays([...relays, ...toAdd])
- setHasChange(true)
- }
- }
-
return (
@@ -137,7 +127,6 @@ export default function HttpRelaysSetting() {
{t('write relays description')}
{t('read & write relays notice')}
-
([])
/** One-shot per timeline init: after an all-failed relay wave, try {@link FAST_READ_RELAY_URLS}. */
const publicReadFallbackAttemptedRef = useRef(false)
+ /** Avoid subscribe storms when the tab stays empty (dead relays): visibility resume used to call `refresh()` every few seconds. */
+ const blankFeedVisibilityResumeRetryAtRef = useRef(0)
+ const refreshScheduleTimeoutRef = useRef | null>(null)
const relayAuthoritativeFeedOnlyRef = useRef(relayAuthoritativeFeedOnly)
relayAuthoritativeFeedOnlyRef.current = relayAuthoritativeFeedOnly
/**
@@ -1048,7 +1053,7 @@ const NoteList = forwardRef(
publicReadFallbackAttemptedRef.current = false
setFeedTimelineEmptyUiReady(false)
setFeedSubscribeRelayOutcomes([])
- }, [timelineSubscriptionKey, refreshCount])
+ }, [timelineSubscriptionKey, subRequestsKey, refreshCount])
useEffect(() => {
feedProfileBatchGenRef.current += 1
@@ -1663,12 +1668,16 @@ const NoteList = forwardRef(
}, [])
const refresh = useCallback(() => {
+ if (refreshScheduleTimeoutRef.current) {
+ clearTimeout(refreshScheduleTimeoutRef.current)
+ refreshScheduleTimeoutRef.current = null
+ }
+ blankFeedVisibilityResumeRetryAtRef.current = 0
+ publicReadFallbackAttemptedRef.current = false
scrollToTop()
- // Short delay so scroll-to-top commits before tearing the timeline (avoids merge races); 500ms made
- // refresh feel broken on slow tabs (e.g. Gallery) when users clicked again thinking nothing happened.
- setTimeout(() => {
- setRefreshCount((count) => count + 1)
- }, 80)
+ setLoading(true)
+ setFeedTimelineEmptyUiReady(false)
+ setRefreshCount((count) => count + 1)
}, [scrollToTop])
const flushPendingNewEventsIntoTimeline = useCallback(() => {
@@ -2064,6 +2073,13 @@ const NoteList = forwardRef(
hostPrimaryPageNameRef.current === 'profile' ||
isProfileTimelineSubscriptionKey(timelineSubscriptionKey)
+ const profileMappedForRefresh = isProfileTimelineFeed
+ ? (mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>)
+ : null
+ const profileAuthorWarmSpecForRefresh = profileMappedForRefresh
+ ? getProfileAuthorWarmupSpec(profileMappedForRefresh)
+ : null
+
/**
* Relay kindless firehose: keep the full batch. Else when the kind picker applies, narrow like
* {@link applyKindPickerInUi}. Remaining spell paths use kinds-only narrowing when client-side kind filter runs.
@@ -2106,6 +2122,61 @@ const NoteList = forwardRef(
? ALGO_LIMIT
: LIMIT
+ /** Manual refresh on profile feeds: bounded fetch in parallel with subscribe (don't wait for EOSE outcomes). */
+ if (userPulledRefresh && profileAuthorWarmSpecForRefresh && profileMappedForRefresh) {
+ publicReadFallbackAttemptedRef.current = true
+ const pullRefreshRelays = dedupeNormalizeRelayUrlsOrdered([
+ ...getProfileAuthorWarmupRelayUrls(profileMappedForRefresh),
+ ...FAST_READ_RELAY_URLS,
+ ...PROFILE_RELAY_URLS
+ ]).slice(0, 24)
+ void (async () => {
+ try {
+ const fetched = await client.fetchEvents(
+ pullRefreshRelays,
+ {
+ authors: [profileAuthorWarmSpecForRefresh.author],
+ kinds: profileAuthorWarmSpecForRefresh.kinds,
+ limit: eventCapEarly
+ },
+ {
+ cache: true,
+ eoseTimeout: 3500,
+ globalTimeout: 22_000,
+ firstRelayResultGraceMs: false
+ }
+ )
+ if (!effectActive || timelineEffectStale()) return
+ if (fetched.length === 0) return
+ const narrowedFetch = narrowLiveBatch(fetched)
+ if (narrowedFetch.length === 0) return
+ setEvents((prev) => {
+ const merged = collapseDuplicateNip18RepostTimelineRows(
+ mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays)
+ )
+ if (merged.length > 0) {
+ timelineMergeBootstrapRef.current = merged.slice()
+ }
+ lastEventsForTimelinePrefetchRef.current = merged
+ return merged
+ })
+ setNewEvents([])
+ setShowCount(revealBatchSize ?? SHOW_COUNT)
+ feedRelayReturnedAnyEventRef.current = true
+ setLoading(false)
+ feedPaintRelayPendingRef.current = true
+ feedPaintRelayMetaRef.current = {
+ variant: 'profile_pull_refresh',
+ mergedCount: narrowedFetch.length
+ }
+ setFeedEmptyToastGateTick((n) => n + 1)
+ setFeedTimelineEmptyUiReady(true)
+ } catch (e) {
+ logger.warn('[NoteList] Profile pull refresh network fetch failed', { error: e })
+ }
+ })()
+ }
+
const isSpellPageLocalWarmup =
hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0
@@ -2731,10 +2802,9 @@ const NoteList = forwardRef(
const totalRelayUrls = mappedSubRequests.reduce((n, r) => n + r.urls.length, 0)
// Many relays are opened under MAX_CONCURRENT_RELAY_CONNECTIONS; a short race aborts the whole feed.
- const subscribeSetupRaceMs = Math.min(
- 300_000,
- Math.max(90_000, 25_000 + totalRelayUrls * 2_500)
- )
+ const subscribeSetupRaceMs = isProfileTimelineFeed
+ ? Math.min(45_000, Math.max(20_000, 12_000 + totalRelayUrls * 1_500))
+ : Math.min(300_000, Math.max(90_000, 25_000 + totalRelayUrls * 2_500))
let closer: (() => void) | undefined
let timelineKey: string | undefined
@@ -3517,8 +3587,6 @@ const NoteList = forwardRef(
const hasMoreRef = useRef(hasMore)
const timelineKeyRef = useRef(timelineKey)
const blankFeedHiddenAtRef = useRef(null)
- /** Avoid subscribe storms when the tab stays empty (dead relays): visibility resume used to call `refresh()` every few seconds. */
- const blankFeedVisibilityResumeRetryAtRef = useRef(0)
const lastNewNotesAutoFlushMsRef = useRef(0)
useEffect(() => {
@@ -3609,8 +3677,6 @@ const NoteList = forwardRef(
if (publicReadFallbackAttemptedRef.current) return
const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes)
- if (uiStatuses.some((s) => s.success)) return
-
const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current)
if (!mapped.length) return
@@ -3641,6 +3707,10 @@ const NoteList = forwardRef(
)
: []
+ /** EOSE with zero hits still counts as success; profile feeds need fallback until rows are visible. */
+ if (!profileWarm && uiStatuses.some((s) => s.success)) return
+ if (profileWarm && eventsRef.current.length > 0) return
+
const filter: Filter = profileWarm
? {
authors: [profileWarm.author],
@@ -3659,8 +3729,13 @@ const NoteList = forwardRef(
? ALGO_LIMIT
: LIMIT
- const fallbackRelays =
- profileRelayUrls.length > 0 ? profileRelayUrls : FAST_READ_RELAY_URLS
+ const fallbackRelays = profileWarm
+ ? dedupeNormalizeRelayUrlsOrdered([
+ ...profileRelayUrls,
+ ...FAST_READ_RELAY_URLS,
+ ...PROFILE_RELAY_URLS
+ ]).slice(0, 24)
+ : FAST_READ_RELAY_URLS
void (async () => {
try {
diff --git a/src/components/PaymentMethodsSection/index.tsx b/src/components/PaymentMethodsSection/index.tsx
index 6c3aa345..789f8bcf 100644
--- a/src/components/PaymentMethodsSection/index.tsx
+++ b/src/components/PaymentMethodsSection/index.tsx
@@ -60,6 +60,7 @@ export default function PaymentMethodsSection({
type={method.type}
authority={method.authority}
paytoUri={method.payto}
+ displayFormat="full"
pubkey={isZappableLightningPaytoType(method.type) ? recipientPubkey : undefined}
onOpenZap={
isZappableLightningPaytoType(method.type) && onOpenZap
diff --git a/src/components/PaytoLink/index.tsx b/src/components/PaytoLink/index.tsx
index 2e742af6..fbd2d921 100644
--- a/src/components/PaytoLink/index.tsx
+++ b/src/components/PaytoLink/index.tsx
@@ -11,11 +11,14 @@ import {
getPaytoProfileUrl,
isKnownPaytoType,
isLightningPaytoType,
- isZappableLightningPaytoType
+ isZappableLightningPaytoType,
+ flattenPaytoLinkChildText,
+ formatPaytoLinkDisplayText,
+ paytoLinkChildTextLooksLikeAuthority
} from '@/lib/payto'
import PaytoDialog from '@/components/PaytoDialog'
import { HelpCircle } from 'lucide-react'
-import { PRIMARY_LINK_HOVER_CLASS, URI_LINK_CLASS } from '@/lib/link-styles'
+import { URI_LINK_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils'
export default function PaytoLink({
@@ -26,6 +29,8 @@ export default function PaytoLink({
onOpenZap,
className,
children,
+ /** `compact`: `47R4Npvudm... (Monero)` for notes/markup; `full`: show authority as-is (e.g. zap dialog). */
+ displayFormat = 'compact',
/** When set (e.g. Markdown link title), used as the native `title` tooltip instead of the default payto hint. */
linkTitle
}: {
@@ -37,6 +42,7 @@ export default function PaytoLink({
onOpenZap?: (pubkey: string, lightningAuthority: string) => void
className?: string
children?: React.ReactNode
+ displayFormat?: 'compact' | 'full'
linkTitle?: string
}) {
const { t } = useTranslation()
@@ -87,8 +93,19 @@ export default function PaytoLink({
const logoPath = getPaytoLogoPath(type)
const iconChar = getPaytoIconChar(type)
const profileUrl = getPaytoProfileUrl(type, authority)
- const content = children ?? {authority}
+ const childText = flattenPaytoLinkChildText(children)
+ const useCompactDisplay =
+ displayFormat === 'compact' &&
+ (!children || paytoLinkChildTextLooksLikeAuthority(childText, authority, raw))
+ const content = useCompactDisplay ? (
+ {formatPaytoLinkDisplayText(type, authority)}
+ ) : children != null && children !== false ? (
+ children
+ ) : (
+ {authority}
+ )
const overrideTip = linkTitle?.trim()
+ const fullAddressTip = `${displayLabel}: ${authority}`
const iconEl = (
@@ -120,7 +137,11 @@ export default function PaytoLink({
)}
title={
overrideTip ||
- (categoryLabel ? `${displayLabel} (${categoryLabel}): ${t('Open on website')}` : `${displayLabel}: ${t('Open on website')}`)
+ (useCompactDisplay
+ ? fullAddressTip
+ : categoryLabel
+ ? `${displayLabel} (${categoryLabel}): ${t('Open on website')}`
+ : `${displayLabel}: ${t('Open on website')}`)
}
onClick={(e) => e.stopPropagation()}
>
@@ -142,11 +163,13 @@ export default function PaytoLink({
)}
title={
overrideTip ||
- (known && categoryLabel
- ? `${displayLabel} (${categoryLabel}): ${t('Click to open payment options')}`
- : known
- ? `${displayLabel}: ${t('Click to open payment options')}`
- : t('Click to copy address'))
+ (useCompactDisplay
+ ? fullAddressTip
+ : known && categoryLabel
+ ? `${displayLabel} (${categoryLabel}): ${t('Click to open payment options')}`
+ : known
+ ? `${displayLabel}: ${t('Click to open payment options')}`
+ : t('Click to copy address'))
}
>
{iconEl}
diff --git a/src/components/ProfileAbout/index.tsx b/src/components/ProfileAbout/index.tsx
index c10d3b69..8a7a6784 100644
--- a/src/components/ProfileAbout/index.tsx
+++ b/src/components/ProfileAbout/index.tsx
@@ -9,7 +9,6 @@ import {
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import PaytoLink from '@/components/PaytoLink'
import { URI_LINK_CLASS } from '@/lib/link-styles'
-import { cn } from '@/lib/utils'
import { marked } from 'marked'
import {
EmbeddedHashtag,
diff --git a/src/hooks/useProfileAuthorFeedSubRequests.ts b/src/hooks/useProfileAuthorFeedSubRequests.ts
index 332db736..1b92e00b 100644
--- a/src/hooks/useProfileAuthorFeedSubRequests.ts
+++ b/src/hooks/useProfileAuthorFeedSubRequests.ts
@@ -42,7 +42,7 @@ export function useProfileAuthorFeedSubRequests({
} {
const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
- const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
+ const viewerUsesGlobalBootstrap = useGlobalRelayBootstrapDefaults()
const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim()
@@ -54,6 +54,9 @@ export function useProfileAuthorFeedSubRequests({
}
}, [nostr?.pubkey, pubkey])
+ /** Own profile: honor viewer relay prefs. Other profiles: always widen with FAST_READ / profile index relays. */
+ const useGlobalRelayBootstrap = viewerUsesGlobalBootstrap || !includeAuthorLocalRelays
+
const relayListsKey = useMemo(
() => relayListsContentKey(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays]
diff --git a/src/lib/payto-display.test.ts b/src/lib/payto-display.test.ts
new file mode 100644
index 00000000..c8ff8903
--- /dev/null
+++ b/src/lib/payto-display.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import {
+ formatPaytoLinkDisplayText,
+ paytoLinkChildTextLooksLikeAuthority,
+ truncatePaytoAuthority
+} from './payto-display'
+
+describe('truncatePaytoAuthority', () => {
+ it('returns full string when within limit', () => {
+ expect(truncatePaytoAuthority('derjuergen')).toBe('derjuergen')
+ })
+
+ it('truncates to 10 characters', () => {
+ expect(truncatePaytoAuthority('47R4NpvudmrLkLxaf4Uy')).toBe('47R4Npvudm')
+ })
+})
+
+describe('formatPaytoLinkDisplayText', () => {
+ it('formats long monero address with label', () => {
+ expect(
+ formatPaytoLinkDisplayText('monero', '47R4NpvudmrLkLxaf4Uyiq56weFDZko1KeFrY5qUgnJ95X3D1YWYRVASAnMLBgpB5BeSViAVaxLXuFDuup15j3f45NC2WUp')
+ ).toBe('47R4Npvudm... (Monero)')
+ })
+
+ it('formats short paypal handle without ellipsis', () => {
+ expect(formatPaytoLinkDisplayText('paypal', 'derjuergen')).toBe('derjuergen (PayPal)')
+ })
+})
+
+describe('paytoLinkChildTextLooksLikeAuthority', () => {
+ it('detects full authority as link text', () => {
+ const auth = '47R4NpvudmrLkLxaf4Uy'
+ expect(paytoLinkChildTextLooksLikeAuthority(auth, auth, `payto://monero/${auth}`)).toBe(true)
+ })
+
+ it('keeps custom link text', () => {
+ expect(paytoLinkChildTextLooksLikeAuthority('Donate via PayPal', 'derjuergen', 'payto://paypal/derjuergen')).toBe(
+ false
+ )
+ })
+})
diff --git a/src/lib/payto-display.ts b/src/lib/payto-display.ts
new file mode 100644
index 00000000..826adef0
--- /dev/null
+++ b/src/lib/payto-display.ts
@@ -0,0 +1,67 @@
+import { Children, isValidElement, type ReactNode } from 'react'
+import { getPaytoTypeInfo } from '@/lib/payto-registry'
+
+export const PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS = 10
+
+/** First N characters of a payto authority for inline feed/article display. */
+export function truncatePaytoAuthority(
+ authority: string,
+ maxLen = PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS
+): string {
+ const trimmed = authority.trim()
+ if (trimmed.length <= maxLen) return trimmed
+ return trimmed.slice(0, maxLen)
+}
+
+/**
+ * Inline payto label for Markdown/AsciiDoc/notes: `47R4Npvudm... (Monero)`.
+ */
+export function formatPaytoLinkDisplayText(
+ type: string,
+ authority: string,
+ options?: { label?: string; maxAuthorityChars?: number }
+): string {
+ const info = getPaytoTypeInfo(type)
+ const label = (options?.label ?? info?.label ?? type).trim()
+ const maxLen = options?.maxAuthorityChars ?? PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS
+ const trimmed = authority.trim()
+ const short = truncatePaytoAuthority(trimmed, maxLen)
+ const suffix = trimmed.length > short.length ? '...' : ''
+ return `${short}${suffix} (${label})`
+}
+
+/** True when link text is just the raw address/URI (replace with compact display). */
+export function paytoLinkChildTextLooksLikeAuthority(
+ childText: string,
+ authority: string,
+ raw: string
+): boolean {
+ const t = childText.trim()
+ if (!t) return true
+ const auth = authority.trim()
+ const uri = raw.trim()
+ if (t === auth || t === uri) return true
+ if (/^payto:\/\//i.test(t)) return true
+ if (auth.length > PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS) {
+ if (t === auth || t.startsWith(auth.slice(0, PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS))) {
+ return true
+ }
+ }
+ return false
+}
+
+/** Plain text from PaytoLink child nodes (for detecting raw-address link labels). */
+export function flattenPaytoLinkChildText(children: ReactNode): string {
+ const parts: string[] = []
+ Children.forEach(children, (child) => {
+ if (child == null || typeof child === 'boolean') return
+ if (typeof child === 'string' || typeof child === 'number') {
+ parts.push(String(child))
+ return
+ }
+ if (isValidElement(child) && child.props.children != null) {
+ parts.push(flattenPaytoLinkChildText(child.props.children))
+ }
+ })
+ return parts.join('')
+}
diff --git a/src/lib/payto.ts b/src/lib/payto.ts
index e963f6cb..834cbb4e 100644
--- a/src/lib/payto.ts
+++ b/src/lib/payto.ts
@@ -56,3 +56,11 @@ export function buildPaytoUri(type: string, authority: string): string {
const a = encodeURIComponent(authority.trim())
return `payto://${t}/${a}`
}
+
+export {
+ flattenPaytoLinkChildText,
+ formatPaytoLinkDisplayText,
+ paytoLinkChildTextLooksLikeAuthority,
+ PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS,
+ truncatePaytoAuthority
+} from '@/lib/payto-display'
diff --git a/src/lib/profile-relay-search-filters.ts b/src/lib/profile-relay-search-filters.ts
index 71000e9b..f8b17f89 100644
--- a/src/lib/profile-relay-search-filters.ts
+++ b/src/lib/profile-relay-search-filters.ts
@@ -1,6 +1,5 @@
import { METADATA_CO_FETCH_KINDS } from '@/constants'
import type { Filter } from 'nostr-tools'
-import { kinds } from 'nostr-tools'
import { splitNip05Identifier } from '@/lib/nip05'
import { normalizeProfileSearchQueryForMatch } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'