From b22b8273ecc65d38baa0bd475688ba0edec38ad6 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 8 Apr 2026 17:50:51 +0200 Subject: [PATCH] handle offline and low-bandwidth graciously --- src/App.tsx | 2 + src/components/NoteList/index.tsx | 44 ++++++- src/components/SlowConnectionHint/index.tsx | 136 ++++++++++++++++++++ src/providers/ContentPolicyProvider.tsx | 30 +++-- src/services/client.service.ts | 18 ++- vite.config.ts | 55 +++++--- 6 files changed, 258 insertions(+), 27 deletions(-) create mode 100644 src/components/SlowConnectionHint/index.tsx diff --git a/src/App.tsx b/src/App.tsx index 62bb53ad..045d2390 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { LiveActivitiesProvider } from '@/providers/LiveActivitiesProvider' import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider' import { UserTrustProvider } from '@/providers/UserTrustProvider' import { ZapProvider } from '@/providers/ZapProvider' +import SlowConnectionHint from '@/components/SlowConnectionHint' import StartupSessionBanner from '@/components/StartupSessionBanner' import VersionUpdateBanner from '@/components/VersionUpdateBanner' import { PageManager } from './PageManager' @@ -40,6 +41,7 @@ export default function App(): JSX.Element {
+
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index bec5590b..b7189000 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -14,7 +14,7 @@ import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' import logger from '@/lib/logger' -import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -528,7 +528,7 @@ const NoteList = forwardRef( const { startLogin, pubkey } = useNostr() const { isUserTrusted } = useUserTrust() const { mutePubkeySet } = useMuteList() - const { hideContentMentioningMutedUsers } = useContentPolicy() + const { hideContentMentioningMutedUsers, isOffline } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() const { zapReplyThreshold } = useZap() const { favoriteRelays, blockedRelays } = useFavoriteRelays() @@ -1315,6 +1315,18 @@ const NoteList = forwardRef( }, 500) }, [scrollToTop]) + // Re-subscribe whenever connectivity flips so we immediately switch between + // local-only (offline) and normal (online) relay sets without waiting for + // the next user-triggered refresh. + const isOfflineRef = useRef(isOffline) + useEffect(() => { + const prev = isOfflineRef.current + isOfflineRef.current = isOffline + if (prev !== isOffline) { + setRefreshCount((n) => n + 1) + } + }, [isOffline]) + const onPerformFeedFullSearch = useCallback(async () => { if (!showFeedClientFilter) return const reqs = subRequestsRef.current @@ -1468,6 +1480,26 @@ const NoteList = forwardRef( return () => {} } + // Synchronous offline check — must run before the async init() so state + // updates happen in the same React batch as the effect itself. + // If every relay URL in every shard is non-local while offline, show an + // immediate empty state instead of spinning while waiting for connections + // that can never succeed. + if (isOfflineRef.current && subRequestsRef.current.length > 0) { + const hasAnyLocalRelay = subRequestsRef.current.some((req) => + req.urls.some((u) => isLocalNetworkUrl(u)) + ) + if (!hasAnyLocalRelay) { + feedPaintLiveRelayDoneRef.current = true + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) + setLoading(false) + setHasMore(false) + setEvents([]) + return () => {} + } + } + const prevSubKey = prevSubRequestsKeyForTimelineRef.current const userPulledRefresh = refreshCount !== timelineEffectLastRefreshCountRef.current if (userPulledRefresh) { @@ -1541,6 +1573,14 @@ const NoteList = forwardRef( const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const mappedSubRequests = mapLiveSubRequestsForTimeline(subRequestsRef.current) + .map((req) => + isOfflineRef.current + ? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) } + : req + ) + // Drop shards whose every relay was filtered out; avoids timeline-cache + // key collisions where all offline relay-specific views share the same key. + .filter((req) => req.urls.length > 0) const filterMissingKinds = (f: Filter) => !f.kinds || f.kinds.length === 0 const invalidFilters = mappedSubRequests.filter(({ urls, filter: f }) => { diff --git a/src/components/SlowConnectionHint/index.tsx b/src/components/SlowConnectionHint/index.tsx new file mode 100644 index 00000000..d1385fe2 --- /dev/null +++ b/src/components/SlowConnectionHint/index.tsx @@ -0,0 +1,136 @@ +import { Button } from '@/components/ui/button' +import { MEDIA_AUTO_LOAD_POLICY } from '@/constants' +import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { WifiOff, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const SLOW_DISMISSED_KEY = 'slowConnectionHintDismissed' + +function detectConnectionStatus(): { poor: boolean; offline: boolean } { + const offline = !navigator.onLine + const conn = (navigator as any).connection + if (!conn) return { poor: offline, offline } + if (conn.saveData === true) return { poor: true, offline } + if (conn.type === 'none') return { poor: true, offline: true } + const eff: string | undefined = conn.effectiveType + return { poor: offline || eff === 'slow-2g' || eff === '2g', offline } +} + +export default function SlowConnectionHint() { + const { t } = useTranslation() + const { autoplay, setAutoplay, mediaAutoLoadPolicy, setMediaAutoLoadPolicy } = useContentPolicy() + const [status, setStatus] = useState(detectConnectionStatus) + const [slowDismissed, setSlowDismissed] = useState( + () => sessionStorage.getItem(SLOW_DISMISSED_KEY) === 'true' + ) + + useEffect(() => { + const refresh = () => setStatus(detectConnectionStatus()) + window.addEventListener('online', refresh) + window.addEventListener('offline', refresh) + const conn = (navigator as any).connection + conn?.addEventListener('change', refresh) + return () => { + window.removeEventListener('online', refresh) + window.removeEventListener('offline', refresh) + conn?.removeEventListener('change', refresh) + } + }, []) + + // Reset slow-connection dismissal when coming back online so the hint can + // re-appear on the next slow-connection episode. + useEffect(() => { + if (!status.offline && !status.poor) { + sessionStorage.removeItem(SLOW_DISMISSED_KEY) + setSlowDismissed(false) + } + }, [status.offline, status.poor]) + + if (status.offline) { + return ( +
+
+ +
+ {t('Offline mode')} + {' — '} + {t('Only local relays and cached content are available.')} +
+
+
+ ) + } + + const hasExpensiveSettings = + autoplay || mediaAutoLoadPolicy === MEDIA_AUTO_LOAD_POLICY.ALWAYS + + if (!status.poor || !hasExpensiveSettings || slowDismissed) return null + + const handleSaveData = () => { + if (autoplay) setAutoplay(false) + if (mediaAutoLoadPolicy !== MEDIA_AUTO_LOAD_POLICY.NEVER) { + setMediaAutoLoadPolicy(MEDIA_AUTO_LOAD_POLICY.NEVER) + } + dismissSlow() + } + + const dismissSlow = () => { + setSlowDismissed(true) + sessionStorage.setItem(SLOW_DISMISSED_KEY, 'true') + } + + const changesDescription = [ + autoplay ? t('video autoplay off') : '', + mediaAutoLoadPolicy !== MEDIA_AUTO_LOAD_POLICY.NEVER ? t('media loading off') : '' + ] + .filter(Boolean) + .join(', ') + + return ( +
+
+
+ +
+

+ {t('Slow connection detected')} +

+

+ {changesDescription + ? t('Turn on low-bandwidth mode? This will set: {{changes}}.', { + changes: changesDescription + }) + : t('Turn on low-bandwidth mode to reduce data usage.')} +

+
+
+
+ + +
+
+
+ ) +} diff --git a/src/providers/ContentPolicyProvider.tsx b/src/providers/ContentPolicyProvider.tsx index 0595c353..f24a7180 100644 --- a/src/providers/ContentPolicyProvider.tsx +++ b/src/providers/ContentPolicyProvider.tsx @@ -16,6 +16,9 @@ type TContentPolicyContext = { autoLoadMedia: boolean mediaAutoLoadPolicy: TMediaAutoLoadPolicy setMediaAutoLoadPolicy: (policy: TMediaAutoLoadPolicy) => void + + /** True when `navigator.onLine` is false or the connection type is 'none'. */ + isOffline: boolean } const ContentPolicyContext = createContext(undefined) @@ -41,19 +44,27 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode ) const [mediaAutoLoadPolicy, setMediaAutoLoadPolicy] = useState(storage.getMediaAutoLoadPolicy()) const [connectionType, setConnectionType] = useState((navigator as any).connection?.type) + const [isOffline, setIsOffline] = useState( + () => !navigator.onLine || (navigator as any).connection?.type === 'none' + ) useEffect(() => { const connection = (navigator as any).connection - if (!connection) { - setConnectionType(undefined) - return - } - const handleConnectionChange = () => { - setConnectionType(connection.type) + + const refresh = () => { + const conn = (navigator as any).connection + setConnectionType(conn?.type) + setIsOffline(!navigator.onLine || conn?.type === 'none') } - connection.addEventListener('change', handleConnectionChange) + + window.addEventListener('online', refresh) + window.addEventListener('offline', refresh) + connection?.addEventListener('change', refresh) + return () => { - connection.removeEventListener('change', handleConnectionChange) + window.removeEventListener('online', refresh) + window.removeEventListener('offline', refresh) + connection?.removeEventListener('change', refresh) } }, []) @@ -101,7 +112,8 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode setHideContentMentioningMutedUsers: updateHideContentMentioningMutedUsers, autoLoadMedia, mediaAutoLoadPolicy, - setMediaAutoLoadPolicy: updateMediaAutoLoadPolicy + setMediaAutoLoadPolicy: updateMediaAutoLoadPolicy, + isOffline }} > {children} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index b50d7e32..39045b91 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -284,6 +284,12 @@ class ClientService extends EventTarget { url: string, params?: { connectionTimeout?: number; abort?: AbortSignal } ) => { + // While offline, skip any relay that isn't on the local network. + // This prevents a flood of failed WebSocket/HTTP connection attempts across + // every part of the app (feeds, profile lookups, relay-list fetches, etc.). + if (!navigator.onLine && !isLocalNetworkUrl(url)) { + throw new Error(`[offline] skipping non-local relay ${url}`) + } const n = normalizeUrl(url) || url const base = params?.connectionTimeout ?? RELAY_POOL_CONNECTION_TIMEOUT_MS const connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n) @@ -1928,6 +1934,11 @@ class ClientService extends EventTarget { ) { const originalDedupedRelays = Array.from(new Set(urls)) let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) + // While offline, silently drop every non-local relay so nothing is added to + // groupedRequests and no session strike is recorded for a connectivity-induced failure. + if (!navigator.onLine) { + relays = relays.filter((url) => isLocalNetworkUrl(url)) + } const filters = sanitizeSubscribeFiltersBeforeReq(filter) if (filters.length === 0) { logger.debug('[relay-req] batch_skip', { @@ -2284,9 +2295,14 @@ class ClientService extends EventTarget { } = {} ) { let relays = Array.from(new Set(urls)) + // While offline, strip non-local relays before any further processing so the + // capital-letter-tag fallback below cannot re-introduce internet relays. + if (!navigator.onLine) { + relays = relays.filter((url) => isLocalNetworkUrl(url)) + } if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) { relays = relayUrlsStripExtendedTagReqBlocked(relays) - if (relays.length === 0) { + if (relays.length === 0 && navigator.onLine) { relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS]) } } diff --git a/vite.config.ts b/vite.config.ts index 7f81884b..ab4104de 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -331,36 +331,61 @@ export default defineConfig(({ mode }) => { handler: 'NetworkOnly' }, { - urlPattern: /^https:\/\/image\.nostr\.build\/.*/i, + // Well-known nostr media CDNs: cache aggressively since content is addressed by hash + urlPattern: + /^https:\/\/(?:image\.nostr\.build|cdn\.satellite\.earth|nostrimg\.com|void\.cat\/d|files\.sovbit\.host|cdn\.hzrd149\.com|blossom\.band|r2[a-z]?\.primal\.net)\/.*/i, handler: 'CacheFirst', options: { - cacheName: 'nostr-images', + cacheName: 'nostr-media-cdn', expiration: { - maxEntries: 100, - maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days - } + maxEntries: 300, + maxAgeSeconds: 60 * 24 * 60 * 60 // 60 days — hash-addressed, effectively immutable + }, + // Only cache genuine 200 OK responses; prevents opaque/error responses from + // filling storage quota with unusable entries. + cacheableResponse: { statuses: [200] } } }, { - urlPattern: /^https:\/\/cdn\.satellite\.earth\/.*/i, + // Generic cross-origin images by file extension (covers hosts not matched above) + urlPattern: /^https?:\/\/.+\.(?:png|jpg|jpeg|gif|webp|avif|svg|ico)(?:\?.*)?$/i, handler: 'CacheFirst', options: { - cacheName: 'satellite-images', + cacheName: 'external-images', expiration: { - maxEntries: 100, - maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days - } + maxEntries: 300, + maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days + }, + cacheableResponse: { statuses: [200] } } }, { - urlPattern: /^https:\/\/.*\.(?:png|jpg|jpeg|svg|gif|webp)$/i, - handler: 'CacheFirst', + // Audio files (podcasts, voice notes) — stale-while-revalidate so playback starts + // immediately from cache while the network check runs in the background. + urlPattern: /^https?:\/\/.+\.(?:mp3|ogg|opus|flac|m4a|aac|wav)(?:\?.*)?$/i, + handler: 'StaleWhileRevalidate', options: { - cacheName: 'external-images', + cacheName: 'external-audio', expiration: { - maxEntries: 200, + maxEntries: 30, maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days - } + }, + cacheableResponse: { statuses: [200] } + } + }, + { + // NIP-11 relay info documents: short-lived cache so relay metadata is fresh but + // the app can render offline or on a slow connection without blocking on network. + urlPattern: ({ request }: { request: Request }) => + request.headers.get('accept')?.includes('application/nostr+json') ?? false, + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'nip11-relay-info', + expiration: { + maxEntries: 100, + maxAgeSeconds: 60 * 60 // 1 hour + }, + cacheableResponse: { statuses: [200] } } } ]