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'