Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
8123099f1e
  1. 11
      src/components/HttpRelaysSetting/index.tsx
  2. 109
      src/components/NoteList/index.tsx
  3. 1
      src/components/PaymentMethodsSection/index.tsx
  4. 41
      src/components/PaytoLink/index.tsx
  5. 1
      src/components/ProfileAbout/index.tsx
  6. 5
      src/hooks/useProfileAuthorFeedSubRequests.ts
  7. 41
      src/lib/payto-display.test.ts
  8. 67
      src/lib/payto-display.ts
  9. 8
      src/lib/payto.ts
  10. 1
      src/lib/profile-relay-search-filters.ts

11
src/components/HttpRelaysSetting/index.tsx

@ -25,7 +25,6 @@ import MailboxRelay from '../MailboxSetting/MailboxRelay' @@ -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() { @@ -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 (
<div className="space-y-4">
<div className="text-xs text-muted-foreground space-y-1">
@ -137,7 +127,6 @@ export default function HttpRelaysSetting() { @@ -137,7 +127,6 @@ export default function HttpRelaysSetting() {
<div>{t('write relays description')}</div>
<div>{t('read & write relays notice')}</div>
</div>
<DiscoveredRelays onAdd={handleAddDiscovered} />
<RelayCountWarning relays={relays} />
<SaveButton mailboxRelays={relays} hasChange={hasChange} setHasChange={setHasChange} />
<DndContext

109
src/components/NoteList/index.tsx

@ -5,6 +5,7 @@ import { @@ -5,6 +5,7 @@ import {
FAST_READ_RELAY_URLS,
FIRST_RELAY_RESULT_GRACE_MS,
PROFILE_MEDIA_TAB_KINDS,
PROFILE_RELAY_URLS,
SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS,
SINGLE_RELAY_KINDLESS_REQ_LIMIT
} from '@/constants'
@ -23,6 +24,7 @@ import { @@ -23,6 +24,7 @@ import {
isSpellSubRequestsSameFiltersDifferentRelays
} from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
@ -72,7 +74,7 @@ import { useTranslation } from 'react-i18next' @@ -72,7 +74,7 @@ import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import { createPortal } from 'react-dom'
import { toast } from 'sonner'
import { formatPubkey, inviteInputToHexPubkey, normalizeHexPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { formatPubkey, inviteInputToHexPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { usePrimaryPageOptional } from '@/contexts/primary-page-context'
import type { TPrimaryPageName } from '@/PageManager'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
@ -908,6 +910,9 @@ const NoteList = forwardRef( @@ -908,6 +910,9 @@ const NoteList = forwardRef(
const [feedSubscribeRelayOutcomes, setFeedSubscribeRelayOutcomes] = useState<RelayOpTerminalRow[]>([])
/** 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<ReturnType<typeof setTimeout> | null>(null)
const relayAuthoritativeFeedOnlyRef = useRef(relayAuthoritativeFeedOnly)
relayAuthoritativeFeedOnlyRef.current = relayAuthoritativeFeedOnly
/**
@ -1048,7 +1053,7 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -3517,8 +3587,6 @@ const NoteList = forwardRef(
const hasMoreRef = useRef(hasMore)
const timelineKeyRef = useRef(timelineKey)
const blankFeedHiddenAtRef = useRef<number | null>(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( @@ -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( @@ -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( @@ -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 {

1
src/components/PaymentMethodsSection/index.tsx

@ -60,6 +60,7 @@ export default function PaymentMethodsSection({ @@ -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

41
src/components/PaytoLink/index.tsx

@ -11,11 +11,14 @@ import { @@ -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({ @@ -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({ @@ -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({ @@ -87,8 +93,19 @@ export default function PaytoLink({
const logoPath = getPaytoLogoPath(type)
const iconChar = getPaytoIconChar(type)
const profileUrl = getPaytoProfileUrl(type, authority)
const content = children ?? <span className="break-all">{authority}</span>
const childText = flattenPaytoLinkChildText(children)
const useCompactDisplay =
displayFormat === 'compact' &&
(!children || paytoLinkChildTextLooksLikeAuthority(childText, authority, raw))
const content = useCompactDisplay ? (
<span>{formatPaytoLinkDisplayText(type, authority)}</span>
) : children != null && children !== false ? (
children
) : (
<span className="break-all">{authority}</span>
)
const overrideTip = linkTitle?.trim()
const fullAddressTip = `${displayLabel}: ${authority}`
const iconEl = (
<span className="shrink-0 flex items-center justify-center w-4 h-4 text-[1rem] leading-none" aria-hidden>
@ -120,7 +137,11 @@ export default function PaytoLink({ @@ -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({ @@ -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}

1
src/components/ProfileAbout/index.tsx

@ -9,7 +9,6 @@ import { @@ -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,

5
src/hooks/useProfileAuthorFeedSubRequests.ts

@ -42,7 +42,7 @@ export function useProfileAuthorFeedSubRequests({ @@ -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({ @@ -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]

41
src/lib/payto-display.test.ts

@ -0,0 +1,41 @@ @@ -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
)
})
})

67
src/lib/payto-display.ts

@ -0,0 +1,67 @@ @@ -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('')
}

8
src/lib/payto.ts

@ -56,3 +56,11 @@ export function buildPaytoUri(type: string, authority: string): string { @@ -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'

1
src/lib/profile-relay-search-filters.ts

@ -1,6 +1,5 @@ @@ -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'

Loading…
Cancel
Save