+ {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] }
}
}
]