|
|
|
@ -7,6 +7,7 @@ import { Relay, type Filter, matchFilter } from 'nostr-tools'; |
|
|
|
import { config } from './config.js'; |
|
|
|
import { config } from './config.js'; |
|
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; |
|
|
|
import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; |
|
|
|
|
|
|
|
import { getDB } from '../cache/indexeddb-store.js'; |
|
|
|
import { filterEvents, shouldHideEvent } from '../event-filter.js'; |
|
|
|
import { filterEvents, shouldHideEvent } from '../event-filter.js'; |
|
|
|
|
|
|
|
|
|
|
|
export interface PublishOptions { |
|
|
|
export interface PublishOptions { |
|
|
|
@ -38,6 +39,12 @@ class NostrClient { |
|
|
|
private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests
|
|
|
|
private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests
|
|
|
|
private totalActiveRequests = 0; |
|
|
|
private totalActiveRequests = 0; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Failed relay tracking with exponential backoff
|
|
|
|
|
|
|
|
private failedRelays: Map<string, { lastFailure: number; retryAfter: number; failureCount: number }> = new Map(); |
|
|
|
|
|
|
|
private readonly INITIAL_RETRY_DELAY = 5000; // 5 seconds
|
|
|
|
|
|
|
|
private readonly MAX_RETRY_DELAY = 300000; // 5 minutes
|
|
|
|
|
|
|
|
private readonly MAX_FAILURE_COUNT = 10; // After 10 failures, wait max delay
|
|
|
|
|
|
|
|
|
|
|
|
async initialize(): Promise<void> { |
|
|
|
async initialize(): Promise<void> { |
|
|
|
if (this.initialized) return; |
|
|
|
if (this.initialized) return; |
|
|
|
|
|
|
|
|
|
|
|
@ -61,10 +68,40 @@ class NostrClient { |
|
|
|
|
|
|
|
|
|
|
|
async addRelay(url: string): Promise<void> { |
|
|
|
async addRelay(url: string): Promise<void> { |
|
|
|
if (this.relays.has(url)) return; |
|
|
|
if (this.relays.has(url)) return; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if this relay has failed recently and we should wait
|
|
|
|
|
|
|
|
const failureInfo = this.failedRelays.get(url); |
|
|
|
|
|
|
|
if (failureInfo) { |
|
|
|
|
|
|
|
const timeSinceFailure = Date.now() - failureInfo.lastFailure; |
|
|
|
|
|
|
|
if (timeSinceFailure < failureInfo.retryAfter) { |
|
|
|
|
|
|
|
const waitTime = failureInfo.retryAfter - timeSinceFailure; |
|
|
|
|
|
|
|
console.log(`[nostr-client] Relay ${url} failed recently, waiting ${Math.round(waitTime / 1000)}s before retry`); |
|
|
|
|
|
|
|
throw new Error(`Relay failed recently, retry after ${Math.round(waitTime / 1000)}s`); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
try { |
|
|
|
const relay = await Relay.connect(url); |
|
|
|
const relay = await Relay.connect(url); |
|
|
|
this.relays.set(url, relay); |
|
|
|
this.relays.set(url, relay); |
|
|
|
|
|
|
|
// Clear failure tracking on successful connection
|
|
|
|
|
|
|
|
this.failedRelays.delete(url); |
|
|
|
|
|
|
|
console.log(`[nostr-client] Successfully connected to relay: ${url}`); |
|
|
|
} catch (error) { |
|
|
|
} catch (error) { |
|
|
|
|
|
|
|
// Track the failure
|
|
|
|
|
|
|
|
const existingFailure = this.failedRelays.get(url) || { lastFailure: 0, retryAfter: this.INITIAL_RETRY_DELAY, failureCount: 0 }; |
|
|
|
|
|
|
|
const failureCount = existingFailure.failureCount + 1; |
|
|
|
|
|
|
|
const retryAfter = Math.min( |
|
|
|
|
|
|
|
this.INITIAL_RETRY_DELAY * Math.pow(2, Math.min(failureCount - 1, 6)), // Exponential backoff, max 2^6 = 64x
|
|
|
|
|
|
|
|
this.MAX_RETRY_DELAY |
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.failedRelays.set(url, { |
|
|
|
|
|
|
|
lastFailure: Date.now(), |
|
|
|
|
|
|
|
retryAfter, |
|
|
|
|
|
|
|
failureCount |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
console.warn(`[nostr-client] Failed to connect to relay ${url} (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`); |
|
|
|
throw error; |
|
|
|
throw error; |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -114,33 +151,136 @@ class NostrClient { |
|
|
|
|
|
|
|
|
|
|
|
for (const filter of filters) { |
|
|
|
for (const filter of filters) { |
|
|
|
try { |
|
|
|
try { |
|
|
|
if (filter.kinds && filter.kinds.length === 1) { |
|
|
|
let candidateEvents: NostrEvent[] = []; |
|
|
|
const events = await getEventsByKind(filter.kinds[0], filter.limit || 50); |
|
|
|
|
|
|
|
for (const event of events) { |
|
|
|
// Query by kind(s) if specified
|
|
|
|
if (seen.has(event.id)) continue; |
|
|
|
if (filter.kinds && filter.kinds.length > 0) { |
|
|
|
if (matchFilter(filter, event)) { |
|
|
|
// If single kind, use index for efficiency
|
|
|
|
results.push(event); |
|
|
|
if (filter.kinds.length === 1) { |
|
|
|
seen.add(event.id); |
|
|
|
candidateEvents = await getEventsByKind(filter.kinds[0], (filter.limit || 100) * 3); |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
// Multiple kinds - query each and combine
|
|
|
|
|
|
|
|
const allEvents: NostrEvent[] = []; |
|
|
|
|
|
|
|
for (const kind of filter.kinds) { |
|
|
|
|
|
|
|
const kindEvents = await getEventsByKind(kind, (filter.limit || 100) * 3); |
|
|
|
|
|
|
|
allEvents.push(...kindEvents); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
candidateEvents = allEvents; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} else if (filter.authors && filter.authors.length > 0) { |
|
|
|
|
|
|
|
// Query by author(s) if no kinds specified
|
|
|
|
|
|
|
|
if (filter.authors.length === 1) { |
|
|
|
|
|
|
|
candidateEvents = await getEventsByPubkey(filter.authors[0], (filter.limit || 100) * 3); |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
// Multiple authors - query each and combine
|
|
|
|
|
|
|
|
const allEvents: NostrEvent[] = []; |
|
|
|
|
|
|
|
for (const author of filter.authors) { |
|
|
|
|
|
|
|
const authorEvents = await getEventsByPubkey(author, (filter.limit || 100) * 3); |
|
|
|
|
|
|
|
allEvents.push(...authorEvents); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
candidateEvents = allEvents; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
// No specific kind or author - get recent events by created_at
|
|
|
|
|
|
|
|
// This is a fallback for broad queries
|
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const db = await getDB(); |
|
|
|
|
|
|
|
const tx = db.transaction('events', 'readonly'); |
|
|
|
|
|
|
|
const index = tx.store.index('created_at'); |
|
|
|
|
|
|
|
const events: NostrEvent[] = []; |
|
|
|
|
|
|
|
let count = 0; |
|
|
|
|
|
|
|
const limit = (filter.limit || 100) * 3; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Iterate in reverse (newest first)
|
|
|
|
|
|
|
|
let cursor = await index.openCursor(null, 'prev'); |
|
|
|
|
|
|
|
while (cursor && count < limit) { |
|
|
|
|
|
|
|
events.push(cursor.value as NostrEvent); |
|
|
|
|
|
|
|
count++; |
|
|
|
|
|
|
|
cursor = await cursor.continue(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await tx.done; |
|
|
|
|
|
|
|
candidateEvents = events; |
|
|
|
|
|
|
|
} catch (dbError) { |
|
|
|
|
|
|
|
console.error('[nostr-client] Error querying IndexedDB for recent events:', dbError); |
|
|
|
|
|
|
|
candidateEvents = []; |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
if (filter.authors && filter.authors.length === 1) { |
|
|
|
|
|
|
|
const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50); |
|
|
|
console.log(`[nostr-client] Cache query found ${candidateEvents.length} candidate events for filter:`, filter); |
|
|
|
for (const event of events) { |
|
|
|
|
|
|
|
|
|
|
|
// Filter candidates by all filter criteria
|
|
|
|
|
|
|
|
for (const event of candidateEvents) { |
|
|
|
if (seen.has(event.id)) continue; |
|
|
|
if (seen.has(event.id)) continue; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Apply since/until filters
|
|
|
|
|
|
|
|
if (filter.since && event.created_at < filter.since) continue; |
|
|
|
|
|
|
|
if (filter.until && event.created_at > filter.until) continue; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Apply ids filter
|
|
|
|
|
|
|
|
if (filter.ids && filter.ids.length > 0 && !filter.ids.includes(event.id)) continue; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Apply authors filter (if not already used for query)
|
|
|
|
|
|
|
|
if (filter.authors && filter.authors.length > 0 && !filter.authors.includes(event.pubkey)) continue; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Apply kinds filter (if not already used for query)
|
|
|
|
|
|
|
|
if (filter.kinds && filter.kinds.length > 0 && !filter.kinds.includes(event.kind)) continue; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Apply tag filters (#e, #E, #p, #P, #a, #A, #d, etc.)
|
|
|
|
|
|
|
|
// Handle both uppercase and lowercase tag filters (Nostr spec allows both)
|
|
|
|
|
|
|
|
// Ignore relays that don't support the tag filter, they need to be corrected by the relay operator.
|
|
|
|
|
|
|
|
if (filter['#e'] && filter['#e'].length > 0) { |
|
|
|
|
|
|
|
const eventTags = event.tags.filter(t => t[0] === 'e' || t[0] === 'E').map(t => t[1]); |
|
|
|
|
|
|
|
if (!filter['#e'].some(id => eventTags.includes(id))) continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (filter['#E'] && filter['#E'].length > 0) { |
|
|
|
|
|
|
|
const eventTags = event.tags.filter(t => t[0] === 'e' || t[0] === 'E').map(t => t[1]); |
|
|
|
|
|
|
|
if (!filter['#E'].some(id => eventTags.includes(id))) continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (filter['#p'] && filter['#p'].length > 0) { |
|
|
|
|
|
|
|
const pubkeyTags = event.tags.filter(t => t[0] === 'p' || t[0] === 'P').map(t => t[1]); |
|
|
|
|
|
|
|
if (!filter['#p'].some(pk => pubkeyTags.includes(pk))) continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (filter['#P'] && filter['#P'].length > 0) { |
|
|
|
|
|
|
|
const pubkeyTags = event.tags.filter(t => t[0] === 'p' || t[0] === 'P').map(t => t[1]); |
|
|
|
|
|
|
|
if (!filter['#P'].some(pk => pubkeyTags.includes(pk))) continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (filter['#a'] && filter['#a'].length > 0) { |
|
|
|
|
|
|
|
const aTags = event.tags.filter(t => t[0] === 'a' || t[0] === 'A').map(t => t[1]); |
|
|
|
|
|
|
|
if (!filter['#a'].some(a => aTags.includes(a))) continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (filter['#A'] && filter['#A'].length > 0) { |
|
|
|
|
|
|
|
const aTags = event.tags.filter(t => t[0] === 'a' || t[0] === 'A').map(t => t[1]); |
|
|
|
|
|
|
|
if (!filter['#A'].some(a => aTags.includes(a))) continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (filter['#d'] && filter['#d'].length > 0) { |
|
|
|
|
|
|
|
const dTags = event.tags.filter(t => t[0] === 'd' || t[0] === 'D').map(t => t[1]); |
|
|
|
|
|
|
|
if (!filter['#d'].some(d => dTags.includes(d))) continue; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Use matchFilter for final validation
|
|
|
|
if (matchFilter(filter, event)) { |
|
|
|
if (matchFilter(filter, event)) { |
|
|
|
results.push(event); |
|
|
|
results.push(event); |
|
|
|
seen.add(event.id); |
|
|
|
seen.add(event.id); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
} catch (error) { |
|
|
|
} catch (error) { |
|
|
|
// Continue with other filters
|
|
|
|
// Continue with other filters
|
|
|
|
|
|
|
|
console.error('[nostr-client] Error querying cache for filter:', error, filter); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return filterEvents(results); |
|
|
|
// Sort by created_at descending and apply limit
|
|
|
|
|
|
|
|
const sorted = results.sort((a, b) => b.created_at - a.created_at); |
|
|
|
|
|
|
|
const limit = filters[0]?.limit || 100; |
|
|
|
|
|
|
|
const limited = sorted.slice(0, limit); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const filtered = filterEvents(limited); |
|
|
|
|
|
|
|
console.log(`[nostr-client] Cache query: ${limited.length} events before filter, ${filtered.length} after filter`); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return filtered; |
|
|
|
} catch (error) { |
|
|
|
} catch (error) { |
|
|
|
|
|
|
|
console.error('[nostr-client] Error getting cached events:', error); |
|
|
|
return []; |
|
|
|
return []; |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -424,22 +564,30 @@ class NostrClient { |
|
|
|
try { |
|
|
|
try { |
|
|
|
const cachedEvents = await this.getCachedEvents(filters); |
|
|
|
const cachedEvents = await this.getCachedEvents(filters); |
|
|
|
if (cachedEvents.length > 0) { |
|
|
|
if (cachedEvents.length > 0) { |
|
|
|
|
|
|
|
console.log(`[nostr-client] Returning ${cachedEvents.length} cached events for filter:`, filters); |
|
|
|
// Return cached immediately, fetch fresh in background with delay
|
|
|
|
// Return cached immediately, fetch fresh in background with delay
|
|
|
|
|
|
|
|
// Don't pass onUpdate to background fetch to avoid interfering with cached results
|
|
|
|
if (cacheResults) { |
|
|
|
if (cacheResults) { |
|
|
|
|
|
|
|
// Use a longer delay for background refresh to avoid interfering with initial load
|
|
|
|
setTimeout(() => { |
|
|
|
setTimeout(() => { |
|
|
|
const bgKey = `${fetchKey}_bg_${Date.now()}`; |
|
|
|
const bgKey = `${fetchKey}_bg_${Date.now()}`; |
|
|
|
const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout }); |
|
|
|
// Only update cache, don't call onUpdate for background refresh
|
|
|
|
|
|
|
|
const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate: undefined, timeout }); |
|
|
|
this.activeFetches.set(bgKey, bgPromise); |
|
|
|
this.activeFetches.set(bgKey, bgPromise); |
|
|
|
bgPromise.finally(() => { |
|
|
|
bgPromise.finally(() => { |
|
|
|
this.activeFetches.delete(bgKey); |
|
|
|
this.activeFetches.delete(bgKey); |
|
|
|
}).catch(() => { |
|
|
|
}).catch((error) => { |
|
|
|
// Silently fail
|
|
|
|
// Log but don't throw - background refresh failures shouldn't affect cached results
|
|
|
|
|
|
|
|
console.debug('[nostr-client] Background refresh failed:', error); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}, 2000); // 2 second delay for background refresh
|
|
|
|
}, 5000); // 5 second delay for background refresh to avoid interfering
|
|
|
|
} |
|
|
|
} |
|
|
|
return cachedEvents; |
|
|
|
return cachedEvents; |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
console.log(`[nostr-client] No cached events found for filter:`, filters); |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (error) { |
|
|
|
} catch (error) { |
|
|
|
|
|
|
|
console.error('[nostr-client] Error querying cache:', error); |
|
|
|
// Continue to fetch from relays
|
|
|
|
// Continue to fetch from relays
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -460,20 +608,47 @@ class NostrClient { |
|
|
|
): Promise<NostrEvent[]> { |
|
|
|
): Promise<NostrEvent[]> { |
|
|
|
const timeout = options.timeout || config.relayTimeout; |
|
|
|
const timeout = options.timeout || config.relayTimeout; |
|
|
|
|
|
|
|
|
|
|
|
let availableRelays = relays.filter(url => this.relays.has(url)); |
|
|
|
// Filter out relays that have failed recently
|
|
|
|
|
|
|
|
const now = Date.now(); |
|
|
|
|
|
|
|
const availableRelays = relays.filter(url => { |
|
|
|
|
|
|
|
if (this.relays.has(url)) return true; // Already connected
|
|
|
|
|
|
|
|
|
|
|
|
if (availableRelays.length === 0) { |
|
|
|
const failureInfo = this.failedRelays.get(url); |
|
|
|
await Promise.allSettled(relays.map(url => this.addRelay(url).catch(() => null))); |
|
|
|
if (failureInfo) { |
|
|
|
availableRelays = relays.filter(url => this.relays.has(url)); |
|
|
|
const timeSinceFailure = now - failureInfo.lastFailure; |
|
|
|
if (availableRelays.length === 0) { |
|
|
|
if (timeSinceFailure < failureInfo.retryAfter) { |
|
|
|
return []; |
|
|
|
return false; // Skip this relay, it failed recently
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return true; // Can try to connect
|
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Try to connect to relays that aren't already connected
|
|
|
|
|
|
|
|
const relaysToConnect = availableRelays.filter(url => !this.relays.has(url)); |
|
|
|
|
|
|
|
if (relaysToConnect.length > 0) { |
|
|
|
|
|
|
|
await Promise.allSettled( |
|
|
|
|
|
|
|
relaysToConnect.map(url =>
|
|
|
|
|
|
|
|
this.addRelay(url).catch((error) => { |
|
|
|
|
|
|
|
// Error already logged in addRelay
|
|
|
|
|
|
|
|
return null; |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Get list of actually connected relays
|
|
|
|
|
|
|
|
const connectedRelays = availableRelays.filter(url => this.relays.has(url)); |
|
|
|
|
|
|
|
if (connectedRelays.length === 0) { |
|
|
|
|
|
|
|
console.warn(`[nostr-client] No connected relays available for fetch (${relays.length} requested, all failed or unavailable)`); |
|
|
|
|
|
|
|
return []; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`); |
|
|
|
|
|
|
|
|
|
|
|
// Process relays sequentially with throttling to avoid overload
|
|
|
|
// Process relays sequentially with throttling to avoid overload
|
|
|
|
const events: Map<string, NostrEvent> = new Map(); |
|
|
|
const events: Map<string, NostrEvent> = new Map(); |
|
|
|
|
|
|
|
|
|
|
|
for (const relayUrl of availableRelays) { |
|
|
|
for (const relayUrl of connectedRelays) { |
|
|
|
await this.throttledRelayRequest(relayUrl, filters, events, timeout); |
|
|
|
await this.throttledRelayRequest(relayUrl, filters, events, timeout); |
|
|
|
// Small delay between relays
|
|
|
|
// Small delay between relays
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
|
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
|
|
@ -489,8 +664,15 @@ class NostrClient { |
|
|
|
}); |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Only call onUpdate if we got new events AND onUpdate is provided
|
|
|
|
|
|
|
|
// This prevents clearing the UI when background fetch returns fewer results
|
|
|
|
if (options.onUpdate && filtered.length > 0) { |
|
|
|
if (options.onUpdate && filtered.length > 0) { |
|
|
|
|
|
|
|
console.log(`[nostr-client] Fetch returned ${filtered.length} events, calling onUpdate`); |
|
|
|
options.onUpdate(filtered); |
|
|
|
options.onUpdate(filtered); |
|
|
|
|
|
|
|
} else if (options.onUpdate && filtered.length === 0) { |
|
|
|
|
|
|
|
console.log(`[nostr-client] Fetch returned 0 events, skipping onUpdate to preserve cached results`); |
|
|
|
|
|
|
|
} else if (!options.onUpdate) { |
|
|
|
|
|
|
|
console.log(`[nostr-client] Fetch returned ${filtered.length} events (background refresh, no onUpdate)`); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return filtered; |
|
|
|
return filtered; |
|
|
|
|