Browse Source

handle empty fetches more gracefully

imwald
Silberengel 1 month ago
parent
commit
49d7aa7c7a
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 84
      src/components/NoteList/index.tsx
  4. 2
      src/i18n/locales/en.ts
  5. 7
      src/lib/publishing-feedback.tsx
  6. 2
      src/pages/primary/SpellsPage/index.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "20.0.1", "version": "20.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "20.0.1", "version": "20.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "20.0.2", "version": "20.1.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

84
src/components/NoteList/index.tsx

@ -44,9 +44,11 @@ import {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh' import PullToRefresh from 'react-simple-pull-to-refresh'
import { toast } from 'sonner'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
import { Button } from '@/components/ui/button'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 100 // Increased from 200 to load more events per request const LIMIT = 100 // Increased from 200 to load more events per request
@ -117,11 +119,6 @@ const NoteList = forwardRef(
* relay URL set is a strict superset of the old one (which would otherwise keep stale rows). * relay URL set is a strict superset of the old one (which would otherwise keep stale rows).
*/ */
feedTimelineScopeKey, feedTimelineScopeKey,
/**
* Spells / one-shot feeds: when the initial fetch finishes with zero rows, show explicit empty copy
* (see list footer). Does not end loading early loading stays until EOSE, first events, or safety timeouts.
*/
spellFetchTimeoutMs,
/** Spells page: bumps when user picks a feed; used with {@link onSpellFeedFirstPaint}. */ /** Spells page: bumps when user picks a feed; used with {@link onSpellFeedFirstPaint}. */
spellFeedInstrumentToken, spellFeedInstrumentToken,
/** Spells page: fired once when the filtered list first has rows after a picker change. */ /** Spells page: fired once when the filtered list first has rows after a picker change. */
@ -181,8 +178,6 @@ const NoteList = forwardRef(
preserveTimelineOnSubRequestsChange?: boolean preserveTimelineOnSubRequestsChange?: boolean
mergeTimelineWhenSubRequestFiltersMatch?: boolean mergeTimelineWhenSubRequestFiltersMatch?: boolean
feedTimelineScopeKey?: string feedTimelineScopeKey?: string
/** When set (e.g. spells), use explicit empty-feed copy after load completes with no rows. */
spellFetchTimeoutMs?: number
spellFeedInstrumentToken?: number spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
timelineLoadingSafetyTimeoutMs?: number timelineLoadingSafetyTimeoutMs?: number
@ -234,6 +229,10 @@ const NoteList = forwardRef(
const feedPaintRelayMetaRef = useRef<Record<string, unknown> | null>(null) const feedPaintRelayMetaRef = useRef<Record<string, unknown> | null>(null)
/** First live `onEvents` paint per timeline init (rows or terminal EOSE). */ /** First live `onEvents` paint per timeline init (rows or terminal EOSE). */
const feedPaintLiveRelayDoneRef = useRef(false) const feedPaintLiveRelayDoneRef = useRef(false)
/** True if any timeline `onEvents` batch had `batch.length > 0`, or one-shot fetches returned any raw events (before UI filters). */
const feedRelayReturnedAnyEventRef = useRef(false)
/** Dedupe {@link toast.error} when relays return nothing for a feed load. */
const emptyRelayNoHitsToastKeyRef = useRef('')
const [feedProfileBatch, setFeedProfileBatch] = useState<{ const [feedProfileBatch, setFeedProfileBatch] = useState<{
profiles: Map<string, TProfile> profiles: Map<string, TProfile>
@ -680,6 +679,7 @@ const NoteList = forwardRef(
feedPaintRelayPendingRef.current = false feedPaintRelayPendingRef.current = false
feedPaintRelayMetaRef.current = null feedPaintRelayMetaRef.current = null
feedPaintLiveRelayDoneRef.current = false feedPaintLiveRelayDoneRef.current = false
feedRelayReturnedAnyEventRef.current = false
// Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton. // Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton.
const keepRowsVisible = const keepRowsVisible =
@ -781,6 +781,9 @@ const NoteList = forwardRef(
) )
) )
if (!effectActive) return undefined if (!effectActive) return undefined
if (batches.some((b) => b.length > 0)) {
feedRelayReturnedAnyEventRef.current = true
}
const byId = new Map<string, Event>() const byId = new Map<string, Event>()
for (const ev of batches.flat()) { for (const ev of batches.flat()) {
const prev = byId.get(ev.id) const prev = byId.get(ev.id)
@ -880,6 +883,9 @@ const NoteList = forwardRef(
{ {
onEvents: (batch: Event[], eosed: boolean) => { onEvents: (batch: Event[], eosed: boolean) => {
if (!effectActive) return if (!effectActive) return
if (batch.length > 0) {
feedRelayReturnedAnyEventRef.current = true
}
const narrowed = narrowLiveBatch(batch) const narrowed = narrowLiveBatch(batch)
if (!feedPaintLiveRelayDoneRef.current) { if (!feedPaintLiveRelayDoneRef.current) {
if (narrowed.length > 0) { if (narrowed.length > 0) {
@ -978,6 +984,7 @@ const NoteList = forwardRef(
}, },
onNew: (event: Event) => { onNew: (event: Event) => {
if (!effectActive) return if (!effectActive) return
feedRelayReturnedAnyEventRef.current = true
if (!useFilterAsIs && !showKinds.includes(event.kind)) return if (!useFilterAsIs && !showKinds.includes(event.kind)) return
if (clientSideKindFilter && useFilterAsIs && !showKinds.includes(event.kind)) return if (clientSideKindFilter && useFilterAsIs && !showKinds.includes(event.kind)) return
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
@ -1140,6 +1147,7 @@ const NoteList = forwardRef(
const loadingRef = useRef(loading) const loadingRef = useRef(loading)
const hasMoreRef = useRef(hasMore) const hasMoreRef = useRef(hasMore)
const timelineKeyRef = useRef(timelineKey) const timelineKeyRef = useRef(timelineKey)
const blankFeedHiddenAtRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
showCountRef.current = showCount showCountRef.current = showCount
@ -1148,6 +1156,35 @@ const NoteList = forwardRef(
useEffect(() => { useEffect(() => {
loadingRef.current = loading loadingRef.current = loading
}, [loading]) }, [loading])
useEffect(() => {
if (loading || events.length > 0) return
if (!subRequests.length) return
const toastKey = `${timelineSubscriptionKey}|${refreshCount}`
const debounceMs = 1_600
const timer = window.setTimeout(() => {
if (loadingRef.current) return
if (eventsRef.current.length > 0) return
if (!subRequestsRef.current.length) return
if (feedRelayReturnedAnyEventRef.current) return
if (emptyRelayNoHitsToastKeyRef.current === toastKey) return
emptyRelayNoHitsToastKeyRef.current = toastKey
toast.error(
t(
'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.'
)
)
}, debounceMs)
return () => window.clearTimeout(timer)
}, [
loading,
events.length,
subRequests.length,
timelineSubscriptionKey,
refreshCount,
t
])
useEffect(() => { useEffect(() => {
hasMoreRef.current = hasMore hasMoreRef.current = hasMore
@ -1157,6 +1194,26 @@ const NoteList = forwardRef(
timelineKeyRef.current = timelineKey timelineKeyRef.current = timelineKey
}, [timelineKey]) }, [timelineKey])
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === 'hidden') {
blankFeedHiddenAtRef.current = Date.now()
return
}
const hidAt = blankFeedHiddenAtRef.current
blankFeedHiddenAtRef.current = null
const hiddenMs = hidAt != null ? Date.now() - hidAt : 0
if (hiddenMs < 1500) return
if (loadingRef.current) return
if (eventsRef.current.length > 0) return
if (!subRequestsRef.current.length) return
logger.info('[NoteList] Blank feed — auto-retry after tab resume', { hiddenMs })
refresh()
}
document.addEventListener('visibilitychange', onVisibility)
return () => document.removeEventListener('visibilitychange', onVisibility)
}, [refresh])
useEffect(() => { useEffect(() => {
const options: IntersectionObserverInit = { const options: IntersectionObserverInit = {
root: null, root: null,
@ -1555,9 +1612,16 @@ const NoteList = forwardRef(
</div> </div>
) : events.length > 0 ? ( ) : events.length > 0 ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div> <div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
) : (spellFetchTimeoutMs != null && spellFetchTimeoutMs > 0) || oneShotFetch ? ( ) : !loading && subRequests.length > 0 ? (
<div ref={bottomRef} className="mt-6 px-4 text-center text-sm text-muted-foreground"> <div
{t('No posts loaded for this feed. Try refreshing.')} ref={bottomRef}
className="mt-6 flex min-h-[35vh] flex-col items-center justify-start gap-4 px-4 text-center text-sm text-muted-foreground"
role="status"
>
<p>{t('No posts loaded for this feed. Try refreshing.')}</p>
<Button type="button" variant="outline" size="sm" onClick={() => refresh()}>
{t('Refresh')}
</Button>
</div> </div>
) : ( ) : (
<div ref={bottomRef} className="mt-2 min-h-4" aria-hidden /> <div ref={bottomRef} className="mt-2 min-h-4" aria-hidden />

2
src/i18n/locales/en.ts

@ -681,6 +681,8 @@ export default {
'Nothing to load for this feed.': 'Nothing to load for this feed.', 'Nothing to load for this feed.': 'Nothing to load for this feed.',
'No posts loaded for this feed. Try refreshing.': 'No posts loaded for this feed. Try refreshing.':
'No posts loaded for this feed. Try refreshing.', 'No posts loaded for this feed. Try refreshing.',
'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.':
'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.',
'Republish to ...': 'Republish to ...', 'Republish to ...': 'Republish to ...',
'All available relays': 'All available relays', 'All available relays': 'All available relays',
'All active relays (monitoring list)': 'All active relays (monitoring list)', 'All active relays (monitoring list)': 'All active relays (monitoring list)',

7
src/lib/publishing-feedback.tsx

@ -64,7 +64,12 @@ export function showPublishingFeedback(
const { relayStatuses, successCount, totalCount } = result const { relayStatuses, successCount, totalCount } = result
if (relayStatuses.length === 0) { if (relayStatuses.length === 0) {
// Fallback for events without relay status tracking // e.g. publishEvent with zero target relays still returns { relayStatuses: [] }; must not use success styling
const publishFailed = result.successCount < 1 || result.success === false
if (publishFailed) {
toast.error(message, { duration: 4000 })
return
}
if (publishSuccessToastsEnabled()) { if (publishSuccessToastsEnabled()) {
toast.success(message, { duration: 2000 }) toast.success(message, { duration: 2000 })
} else { } else {

2
src/pages/primary/SpellsPage/index.tsx

@ -1548,7 +1548,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
subRequests={subRequests} subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey} feedSubscriptionKey={spellFeedSubscriptionKey}
showKinds={showKinds} showKinds={showKinds}
spellFetchTimeoutMs={1}
spellFeedInstrumentToken={spellFeedInstrumentToken} spellFeedInstrumentToken={spellFeedInstrumentToken}
onSpellFeedFirstPaint={handleSpellFeedFirstPaint} onSpellFeedFirstPaint={handleSpellFeedFirstPaint}
timelineLoadingSafetyTimeoutMs={ timelineLoadingSafetyTimeoutMs={
@ -1597,7 +1596,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
subRequests={subRequests} subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey} feedSubscriptionKey={spellFeedSubscriptionKey}
showKinds={showKinds} showKinds={showKinds}
spellFetchTimeoutMs={1}
spellFeedInstrumentToken={spellFeedInstrumentToken} spellFeedInstrumentToken={spellFeedInstrumentToken}
onSpellFeedFirstPaint={handleSpellFeedFirstPaint} onSpellFeedFirstPaint={handleSpellFeedFirstPaint}
useFilterAsIs useFilterAsIs

Loading…
Cancel
Save