diff --git a/package-lock.json b/package-lock.json index e3d40c28..26074fa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "20.0.1", + "version": "20.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "20.0.1", + "version": "20.1.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 1aa68ea3..10772a4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "private": true, "type": "module", diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 9f3240be..207d8989 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -44,9 +44,11 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' +import { toast } from 'sonner' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import type { TProfile } from '@/types' +import { Button } from '@/components/ui/button' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' 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). */ 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}. */ spellFeedInstrumentToken, /** Spells page: fired once when the filtered list first has rows after a picker change. */ @@ -181,8 +178,6 @@ const NoteList = forwardRef( preserveTimelineOnSubRequestsChange?: boolean mergeTimelineWhenSubRequestFiltersMatch?: boolean feedTimelineScopeKey?: string - /** When set (e.g. spells), use explicit empty-feed copy after load completes with no rows. */ - spellFetchTimeoutMs?: number spellFeedInstrumentToken?: number onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void timelineLoadingSafetyTimeoutMs?: number @@ -234,6 +229,10 @@ const NoteList = forwardRef( const feedPaintRelayMetaRef = useRef | null>(null) /** First live `onEvents` paint per timeline init (rows or terminal EOSE). */ 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<{ profiles: Map @@ -680,6 +679,7 @@ const NoteList = forwardRef( feedPaintRelayPendingRef.current = false feedPaintRelayMetaRef.current = null feedPaintLiveRelayDoneRef.current = false + feedRelayReturnedAnyEventRef.current = false // Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton. const keepRowsVisible = @@ -781,6 +781,9 @@ const NoteList = forwardRef( ) ) if (!effectActive) return undefined + if (batches.some((b) => b.length > 0)) { + feedRelayReturnedAnyEventRef.current = true + } const byId = new Map() for (const ev of batches.flat()) { const prev = byId.get(ev.id) @@ -880,6 +883,9 @@ const NoteList = forwardRef( { onEvents: (batch: Event[], eosed: boolean) => { if (!effectActive) return + if (batch.length > 0) { + feedRelayReturnedAnyEventRef.current = true + } const narrowed = narrowLiveBatch(batch) if (!feedPaintLiveRelayDoneRef.current) { if (narrowed.length > 0) { @@ -978,6 +984,7 @@ const NoteList = forwardRef( }, onNew: (event: Event) => { if (!effectActive) return + feedRelayReturnedAnyEventRef.current = true if (!useFilterAsIs && !showKinds.includes(event.kind)) return if (clientSideKindFilter && useFilterAsIs && !showKinds.includes(event.kind)) return if (event.kind === kinds.ShortTextNote) { @@ -1140,6 +1147,7 @@ const NoteList = forwardRef( const loadingRef = useRef(loading) const hasMoreRef = useRef(hasMore) const timelineKeyRef = useRef(timelineKey) + const blankFeedHiddenAtRef = useRef(null) useEffect(() => { showCountRef.current = showCount @@ -1148,6 +1156,35 @@ const NoteList = forwardRef( useEffect(() => { loadingRef.current = 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(() => { hasMoreRef.current = hasMore @@ -1157,6 +1194,26 @@ const NoteList = forwardRef( timelineKeyRef.current = 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(() => { const options: IntersectionObserverInit = { root: null, @@ -1555,9 +1612,16 @@ const NoteList = forwardRef( ) : events.length > 0 ? (
{t('no more notes')}
- ) : (spellFetchTimeoutMs != null && spellFetchTimeoutMs > 0) || oneShotFetch ? ( -
- {t('No posts loaded for this feed. Try refreshing.')} + ) : !loading && subRequests.length > 0 ? ( +
+

{t('No posts loaded for this feed. Try refreshing.')}

+
) : (
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ecd5067a..7b8572a4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -681,6 +681,8 @@ export default { '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.', + '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 ...', 'All available relays': 'All available relays', 'All active relays (monitoring list)': 'All active relays (monitoring list)', diff --git a/src/lib/publishing-feedback.tsx b/src/lib/publishing-feedback.tsx index 93e63fc6..59172a57 100644 --- a/src/lib/publishing-feedback.tsx +++ b/src/lib/publishing-feedback.tsx @@ -64,7 +64,12 @@ export function showPublishingFeedback( const { relayStatuses, successCount, totalCount } = result 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()) { toast.success(message, { duration: 2000 }) } else { diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 9c5f0f1d..40452f97 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -1548,7 +1548,6 @@ const SpellsPage = forwardRef(function SpellsPage( subRequests={subRequests} feedSubscriptionKey={spellFeedSubscriptionKey} showKinds={showKinds} - spellFetchTimeoutMs={1} spellFeedInstrumentToken={spellFeedInstrumentToken} onSpellFeedFirstPaint={handleSpellFeedFirstPaint} timelineLoadingSafetyTimeoutMs={ @@ -1597,7 +1596,6 @@ const SpellsPage = forwardRef(function SpellsPage( subRequests={subRequests} feedSubscriptionKey={spellFeedSubscriptionKey} showKinds={showKinds} - spellFetchTimeoutMs={1} spellFeedInstrumentToken={spellFeedInstrumentToken} onSpellFeedFirstPaint={handleSpellFeedFirstPaint} useFilterAsIs