Browse Source

handle offline and low-bandwidth graciously

imwald
Silberengel 3 weeks ago
parent
commit
b22b8273ec
  1. 2
      src/App.tsx
  2. 44
      src/components/NoteList/index.tsx
  3. 136
      src/components/SlowConnectionHint/index.tsx
  4. 30
      src/providers/ContentPolicyProvider.tsx
  5. 18
      src/services/client.service.ts
  6. 55
      vite.config.ts

2
src/App.tsx

@ -25,6 +25,7 @@ import { LiveActivitiesProvider } from '@/providers/LiveActivitiesProvider' @@ -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 { @@ -40,6 +41,7 @@ export default function App(): JSX.Element {
<div className="flex min-h-[100dvh] flex-col">
<VersionUpdateBanner />
<StartupSessionBanner />
<SlowConnectionHint />
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<ZapProvider>
<FavoriteRelaysProvider>

44
src/components/NoteList/index.tsx

@ -14,7 +14,7 @@ import { @@ -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( @@ -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( @@ -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( @@ -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( @@ -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 }) => {

136
src/components/SlowConnectionHint/index.tsx

@ -0,0 +1,136 @@ @@ -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 (
<div
role="status"
aria-live="polite"
className="shrink-0 border-b border-border bg-muted/60 px-4 py-2.5"
>
<div className="flex min-w-0 items-center gap-2.5 text-muted-foreground">
<WifiOff className="size-4 shrink-0" aria-hidden />
<div className="min-w-0 flex-1 text-sm">
<span className="font-medium text-foreground">{t('Offline mode')}</span>
{' — '}
{t('Only local relays and cached content are available.')}
</div>
</div>
</div>
)
}
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 (
<div
role="alert"
className="shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-900/20"
>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center gap-3">
<WifiOff className="size-5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('Slow connection detected')}
</p>
<p className="text-xs text-amber-600 dark:text-amber-300">
{changesDescription
? t('Turn on low-bandwidth mode? This will set: {{changes}}.', {
changes: changesDescription
})
: t('Turn on low-bandwidth mode to reduce data usage.')}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
onClick={handleSaveData}
className="bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-700 dark:hover:bg-amber-600"
>
{t('Save data')}
</Button>
<Button
variant="ghost"
size="icon"
onClick={dismissSlow}
aria-label={t('Dismiss')}
className="size-8 text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-200"
>
<X className="size-4" />
</Button>
</div>
</div>
</div>
)
}

30
src/providers/ContentPolicyProvider.tsx

@ -16,6 +16,9 @@ type TContentPolicyContext = { @@ -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<TContentPolicyContext | undefined>(undefined)
@ -41,19 +44,27 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode @@ -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 @@ -101,7 +112,8 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
setHideContentMentioningMutedUsers: updateHideContentMentioningMutedUsers,
autoLoadMedia,
mediaAutoLoadPolicy,
setMediaAutoLoadPolicy: updateMediaAutoLoadPolicy
setMediaAutoLoadPolicy: updateMediaAutoLoadPolicy,
isOffline
}}
>
{children}

18
src/services/client.service.ts

@ -284,6 +284,12 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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])
}
}

55
vite.config.ts

@ -331,36 +331,61 @@ export default defineConfig(({ mode }) => { @@ -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] }
}
}
]

Loading…
Cancel
Save