- ❤️ {heartCount > 0 ? heartCount : ''}
+ ❤️
-
{#if showMenu}
{#if isMobile}
-
{ showMenu = false; emojiSearchQuery = ''; }}
@@ -369,8 +303,11 @@
aria-label="Close emoji menu"
>
{/if}
-
@@ -481,17 +412,6 @@
margin-top: 0.5rem;
}
- @media (max-width: 768px) {
- .Feed-reaction-buttons {
- gap: 0.375rem; /* Smaller gap on mobile */
- }
-
- .reaction-btn {
- padding: 0.25rem 0.5rem; /* Smaller padding on mobile */
- font-size: 0.8125rem; /* Slightly smaller text */
- }
- }
-
.reaction-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
@@ -534,20 +454,16 @@
position: relative;
}
- .heart-btn {
- /* Heart button styling */
- }
-
.reaction-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 0.5rem;
background: var(--fog-post, #ffffff);
- border: 1px solid var(--fog-border, #e5e7eb);
+ border: 2px solid var(--fog-border, #cbd5e1);
border-radius: 0.5rem;
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
- padding: 0.5rem;
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2), 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+ padding: 0.75rem;
z-index: 1000;
min-width: 200px;
max-width: 300px;
@@ -555,9 +471,6 @@
display: flex;
flex-direction: column;
overflow: hidden;
- /* Ensure scrollbar is always visible */
- scrollbar-width: thin;
- scrollbar-color: var(--fog-border, #e5e7eb) var(--fog-post, #ffffff);
}
.reaction-menu-content {
@@ -566,7 +479,6 @@
flex: 1;
}
- /* Mobile drawer styles */
.reaction-menu.mobile-drawer {
position: fixed;
bottom: 0;
@@ -579,7 +491,7 @@
max-height: 70vh;
min-width: auto;
width: 100%;
- box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1), 0 -2px 4px -1px rgba(0, 0, 0, 0.06);
+ box-shadow: 0 -10px 25px -5px rgba(0, 0, 0, 0.2), 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
animation: slideUp 0.3s ease-out;
}
@@ -592,7 +504,6 @@
}
}
- /* Backdrop for mobile drawer */
.mobile-drawer-backdrop {
position: fixed;
top: 0;
@@ -612,10 +523,6 @@
opacity: 1;
}
}
-
- .reaction-menu.mobile-drawer {
- z-index: 1000;
- }
.reaction-menu.menu-below {
bottom: auto;
@@ -624,40 +531,10 @@
margin-top: 0.5rem;
}
- .reaction-menu::-webkit-scrollbar {
- width: 8px;
- }
-
- .reaction-menu::-webkit-scrollbar-track {
- background: var(--fog-post, #ffffff);
- border-radius: 0.5rem;
- }
-
- .reaction-menu::-webkit-scrollbar-thumb {
- background: var(--fog-border, #e5e7eb);
- border-radius: 4px;
- }
-
- .reaction-menu::-webkit-scrollbar-thumb:hover {
- background: var(--fog-accent, #64748b);
- }
-
- :global(.dark) .reaction-menu::-webkit-scrollbar-track {
- background: var(--fog-dark-post, #1f2937);
- }
-
- :global(.dark) .reaction-menu::-webkit-scrollbar-thumb {
- background: var(--fog-dark-border, #374151);
- }
-
- :global(.dark) .reaction-menu::-webkit-scrollbar-thumb:hover {
- background: var(--fog-dark-accent, #64748b);
- }
-
:global(.dark) .reaction-menu {
background: var(--fog-dark-post, #1f2937);
- border-color: var(--fog-dark-border, #374151);
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
+ border-color: var(--fog-dark-border, #475569);
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.reaction-menu-grid {
@@ -679,6 +556,11 @@
align-items: center;
justify-content: center;
min-height: 2.5rem;
+ filter: none !important;
+ }
+
+ .reaction-menu-item * {
+ filter: none !important;
}
.reaction-menu-item:hover {
@@ -729,7 +611,7 @@
}
:global(.dark) .custom-emojis-section {
- border-top-color: var(--fog-dark-border, #374151);
+ border-top-color: var(--fog-dark-border, #475569);
}
.custom-emojis-label {
@@ -756,21 +638,20 @@
.emoji-search-container {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
- padding-top: 0;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
display: block;
width: 100%;
}
:global(.dark) .emoji-search-container {
- border-bottom-color: var(--fog-dark-border, #374151);
+ border-bottom-color: var(--fog-dark-border, #475569);
}
.emoji-search-input {
width: 100%;
- padding: 0.5rem;
- border: 1px solid var(--fog-border, #e5e7eb);
- border-radius: 0.25rem;
+ padding: 0.625rem;
+ border: 1.5px solid var(--fog-border, #cbd5e1);
+ border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
@@ -781,21 +662,48 @@
.emoji-search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
- box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
+ box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.15);
}
:global(.dark) .emoji-search-input {
background: var(--fog-dark-post, #1f2937);
- border-color: var(--fog-dark-border, #374151);
+ border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .emoji-search-input:focus {
border-color: var(--fog-dark-accent, #64748b);
- box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.2);
+ box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.3);
+ }
+
+ .reaction-display {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ color: var(--fog-text, #1f2937);
+ user-select: none;
+ }
+
+ :global(.dark) .reaction-display {
+ color: var(--fog-dark-text, #f9fafb);
+ }
+
+ .reaction-display.active {
+ opacity: 0.8;
}
- /* Adjust grid for mobile */
+ .reaction-count-text {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--fog-text-light, #6b7280);
+ }
+
+ :global(.dark) .reaction-count-text {
+ color: var(--fog-dark-text-light, #9ca3af);
+ }
+
@media (max-width: 768px) {
.reaction-menu-grid {
grid-template-columns: repeat(8, 1fr);
diff --git a/src/lib/modules/threads/ThreadList.svelte b/src/lib/modules/threads/ThreadList.svelte
index 2e3e114..5f5bb8f 100644
--- a/src/lib/modules/threads/ThreadList.svelte
+++ b/src/lib/modules/threads/ThreadList.svelte
@@ -97,22 +97,22 @@
// Thread bumping: active threads rise to top
// Batch fetch all comments and reactions at once to avoid concurrent request issues
const threadIds = events.map(e => e.id);
- const commentRelays = relayManager.getCommentReadRelays();
- const reactionRelays = relayManager.getThreadReadRelays();
-
+ const commentRelays = relayManager.getCommentReadRelays();
+ const reactionRelays = relayManager.getThreadReadRelays();
+
// Batch fetch all comments for all threads
const allComments = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': threadIds, '#K': ['11'] }],
- commentRelays,
- { useCache: true }
- );
-
+ commentRelays,
+ { useCache: true }
+ );
+
// Batch fetch all reactions for all threads
const allReactions = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': threadIds }],
- reactionRelays,
- { useCache: true }
- );
+ reactionRelays,
+ { useCache: true }
+ );
// Group comments and reactions by thread ID
const commentsByThread = new Map
();
@@ -147,17 +147,17 @@
? Math.max(...comments.map(c => c.created_at))
: 0;
- const lastReactionTime = reactions.length > 0
+ const lastReactionTime = reactions.length > 0
? Math.max(...reactions.map(r => r.created_at))
- : 0;
-
- const lastActivity = Math.max(
- event.created_at,
- lastCommentTime,
- lastReactionTime
- );
-
- return { event, lastActivity };
+ : 0;
+
+ const lastActivity = Math.max(
+ event.created_at,
+ lastCommentTime,
+ lastReactionTime
+ );
+
+ return { event, lastActivity };
});
return activeSorted
@@ -172,8 +172,8 @@
const allReactionsForUpvotes = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': allThreadIds }],
reactionRelaysForUpvotes,
- { useCache: true }
- );
+ { useCache: true }
+ );
// Group reactions by thread ID
const reactionsByThreadForUpvotes = new Map();
@@ -190,10 +190,10 @@
// Calculate upvote count for each thread
const upvotedSorted = events.map((event) => {
const reactions = reactionsByThreadForUpvotes.get(event.id) || [];
- const upvoteCount = reactions.filter(
- (r) => r.content.trim() === '+' || r.content.trim() === '⬆️' || r.content.trim() === '↑'
- ).length;
- return { event, upvotes: upvoteCount };
+ const upvoteCount = reactions.filter(
+ (r) => r.content.trim() === '+' || r.content.trim() === '⬆️' || r.content.trim() === '↑'
+ ).length;
+ return { event, upvotes: upvoteCount };
});
return upvotedSorted
diff --git a/src/lib/services/cache/event-cache.ts b/src/lib/services/cache/event-cache.ts
index 1b41aa4..f079a70 100644
--- a/src/lib/services/cache/event-cache.ts
+++ b/src/lib/services/cache/event-cache.ts
@@ -14,12 +14,12 @@ export interface CachedEvent extends NostrEvent {
*/
export async function cacheEvent(event: NostrEvent): Promise {
try {
- const db = await getDB();
- const cached: CachedEvent = {
- ...event,
- cached_at: Date.now()
- };
- await db.put('events', cached);
+ const db = await getDB();
+ const cached: CachedEvent = {
+ ...event,
+ cached_at: Date.now()
+ };
+ await db.put('events', cached);
} catch (error) {
console.debug('Error caching event:', error);
// Don't throw - caching failures shouldn't break the app
@@ -31,16 +31,16 @@ export async function cacheEvent(event: NostrEvent): Promise {
*/
export async function cacheEvents(events: NostrEvent[]): Promise {
try {
- const db = await getDB();
- const tx = db.transaction('events', 'readwrite');
- for (const event of events) {
- const cached: CachedEvent = {
- ...event,
- cached_at: Date.now()
- };
- await tx.store.put(cached);
- }
- await tx.done;
+ const db = await getDB();
+ const tx = db.transaction('events', 'readwrite');
+ for (const event of events) {
+ const cached: CachedEvent = {
+ ...event,
+ cached_at: Date.now()
+ };
+ await tx.store.put(cached);
+ }
+ await tx.done;
} catch (error) {
console.debug('Error caching events:', error);
// Don't throw - caching failures shouldn't break the app
@@ -52,7 +52,7 @@ export async function cacheEvents(events: NostrEvent[]): Promise {
*/
export async function getEvent(id: string): Promise {
try {
- const db = await getDB();
+ const db = await getDB();
return await db.get('events', id);
} catch (error) {
console.debug('Error getting event from cache:', error);
@@ -65,21 +65,21 @@ export async function getEvent(id: string): Promise {
*/
export async function getEventsByKind(kind: number, limit?: number): Promise {
try {
- const db = await getDB();
- const tx = db.transaction('events', 'readonly');
- const index = tx.store.index('kind');
- const events: CachedEvent[] = [];
- let count = 0;
-
- for await (const cursor of index.iterate(kind)) {
- if (limit && count >= limit) break;
- events.push(cursor.value);
- count++;
- }
+ const db = await getDB();
+ const tx = db.transaction('events', 'readonly');
+ const index = tx.store.index('kind');
+ const events: CachedEvent[] = [];
+ let count = 0;
+
+ for await (const cursor of index.iterate(kind)) {
+ if (limit && count >= limit) break;
+ events.push(cursor.value);
+ count++;
+ }
- await tx.done;
+ await tx.done;
- return events.sort((a, b) => b.created_at - a.created_at);
+ return events.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.debug('Error getting events by kind from cache:', error);
return [];
@@ -91,21 +91,21 @@ export async function getEventsByKind(kind: number, limit?: number): Promise {
try {
- const db = await getDB();
- const tx = db.transaction('events', 'readonly');
- const index = tx.store.index('pubkey');
- const events: CachedEvent[] = [];
- let count = 0;
-
- for await (const cursor of index.iterate(pubkey)) {
- if (limit && count >= limit) break;
- events.push(cursor.value);
- count++;
- }
+ const db = await getDB();
+ const tx = db.transaction('events', 'readonly');
+ const index = tx.store.index('pubkey');
+ const events: CachedEvent[] = [];
+ let count = 0;
+
+ for await (const cursor of index.iterate(pubkey)) {
+ if (limit && count >= limit) break;
+ events.push(cursor.value);
+ count++;
+ }
- await tx.done;
+ await tx.done;
- return events.sort((a, b) => b.created_at - a.created_at);
+ return events.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.debug('Error getting events by pubkey from cache:', error);
return [];
diff --git a/src/lib/services/cache/indexeddb-store.ts b/src/lib/services/cache/indexeddb-store.ts
index 2be6ef8..2f55d75 100644
--- a/src/lib/services/cache/indexeddb-store.ts
+++ b/src/lib/services/cache/indexeddb-store.ts
@@ -36,30 +36,30 @@ export async function getDB(): Promise> {
if (dbInstance) return dbInstance;
try {
- dbInstance = await openDB(DB_NAME, DB_VERSION, {
- upgrade(db) {
- // Events store
- if (!db.objectStoreNames.contains('events')) {
- const eventStore = db.createObjectStore('events', { keyPath: 'id' });
- eventStore.createIndex('kind', 'kind', { unique: false });
- eventStore.createIndex('pubkey', 'pubkey', { unique: false });
- eventStore.createIndex('created_at', 'created_at', { unique: false });
- }
+ dbInstance = await openDB(DB_NAME, DB_VERSION, {
+ upgrade(db) {
+ // Events store
+ if (!db.objectStoreNames.contains('events')) {
+ const eventStore = db.createObjectStore('events', { keyPath: 'id' });
+ eventStore.createIndex('kind', 'kind', { unique: false });
+ eventStore.createIndex('pubkey', 'pubkey', { unique: false });
+ eventStore.createIndex('created_at', 'created_at', { unique: false });
+ }
- // Profiles store
- if (!db.objectStoreNames.contains('profiles')) {
- db.createObjectStore('profiles', { keyPath: 'pubkey' });
- }
+ // Profiles store
+ if (!db.objectStoreNames.contains('profiles')) {
+ db.createObjectStore('profiles', { keyPath: 'pubkey' });
+ }
- // Keys store
- if (!db.objectStoreNames.contains('keys')) {
- db.createObjectStore('keys', { keyPath: 'id' });
- }
+ // Keys store
+ if (!db.objectStoreNames.contains('keys')) {
+ db.createObjectStore('keys', { keyPath: 'id' });
+ }
- // Search index store
- if (!db.objectStoreNames.contains('search')) {
- db.createObjectStore('search', { keyPath: 'id' });
- }
+ // Search index store
+ if (!db.objectStoreNames.contains('search')) {
+ db.createObjectStore('search', { keyPath: 'id' });
+ }
},
blocked() {
console.warn('IndexedDB is blocked - another tab may have it open');
@@ -120,7 +120,7 @@ export async function getDB(): Promise> {
});
}
- return dbInstance;
+ return dbInstance;
} catch (error) {
console.error('Failed to open IndexedDB:', error);
// Reset instance so we can retry
diff --git a/src/lib/services/cache/profile-cache.ts b/src/lib/services/cache/profile-cache.ts
index ef7c1f0..dfd6e8c 100644
--- a/src/lib/services/cache/profile-cache.ts
+++ b/src/lib/services/cache/profile-cache.ts
@@ -17,13 +17,13 @@ export interface CachedProfile {
export async function cacheProfile(event: NostrEvent): Promise {
if (event.kind !== 0) throw new Error('Not a profile event');
try {
- const db = await getDB();
- const cached: CachedProfile = {
- pubkey: event.pubkey,
- event,
- cached_at: Date.now()
- };
- await db.put('profiles', cached);
+ const db = await getDB();
+ const cached: CachedProfile = {
+ pubkey: event.pubkey,
+ event,
+ cached_at: Date.now()
+ };
+ await db.put('profiles', cached);
} catch (error) {
console.debug('Error caching profile:', error);
// Don't throw - caching failures shouldn't break the app
@@ -35,7 +35,7 @@ export async function cacheProfile(event: NostrEvent): Promise {
*/
export async function getProfile(pubkey: string): Promise {
try {
- const db = await getDB();
+ const db = await getDB();
return await db.get('profiles', pubkey);
} catch (error) {
console.debug('Error getting profile from cache:', error);
@@ -48,19 +48,19 @@ export async function getProfile(pubkey: string): Promise> {
try {
- const db = await getDB();
- const profiles = new Map();
- const tx = db.transaction('profiles', 'readonly');
+ const db = await getDB();
+ const profiles = new Map();
+ const tx = db.transaction('profiles', 'readonly');
- for (const pubkey of pubkeys) {
- const profile = await tx.store.get(pubkey);
- if (profile) {
- profiles.set(pubkey, profile);
- }
+ for (const pubkey of pubkeys) {
+ const profile = await tx.store.get(pubkey);
+ if (profile) {
+ profiles.set(pubkey, profile);
}
+ }
- await tx.done;
- return profiles;
+ await tx.done;
+ return profiles;
} catch (error) {
console.debug('Error getting profiles from cache:', error);
return new Map();
diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts
index 64cd49c..2e61f4e 100644
--- a/src/lib/services/nostr/nostr-client.ts
+++ b/src/lib/services/nostr/nostr-client.ts
@@ -1,6 +1,6 @@
/**
- * Nostr client using nostr-tools
- * Main interface for Nostr operations using only nostr-tools
+ * Nostr client - optimized for low bandwidth and efficiency
+ * Features: request throttling, batching, rate limiting, efficient caching
*/
import { Relay, type Filter, matchFilter } from 'nostr-tools';
@@ -14,148 +14,104 @@ export interface PublishOptions {
skipRelayValidation?: boolean;
}
+interface FetchOptions {
+ useCache?: boolean;
+ cacheResults?: boolean;
+ onUpdate?: (events: NostrEvent[]) => void;
+ timeout?: number;
+}
+
class NostrClient {
private initialized = false;
private relays: Map = new Map();
private subscriptions: Map = new Map();
private nextSubId = 1;
- private activeFetches: Map> = new Map(); // Track active fetches to prevent duplicates
+ private activeFetches: Map> = new Map();
+
+ // Rate limiting and throttling
+ private requestQueue: Array<() => void> = [];
+ private processingQueue = false;
+ private lastRequestTime: Map = new Map(); // relay -> timestamp
+ private activeRequestsPerRelay: Map = new Map();
+ private readonly MIN_REQUEST_INTERVAL = 200; // 200ms between requests to same relay
+ private readonly MAX_CONCURRENT_PER_RELAY = 1; // Only 1 concurrent request per relay
+ private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests
+ private totalActiveRequests = 0;
- /**
- * Initialize the client
- */
async initialize(): Promise {
if (this.initialized) return;
- // Set up global error handler for unhandled promise rejections from relays
- if (typeof window !== 'undefined' && !(window as any).__nostrErrorHandlerSet) {
- (window as any).__nostrErrorHandlerSet = true;
- window.addEventListener('unhandledrejection', (event) => {
- const error = event.reason;
- if (error && typeof error === 'object') {
- const errorMessage = error.message || String(error);
- if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed')) {
- // Suppress these errors as they're handled by our connection management
- event.preventDefault();
- console.debug('Suppressed closed connection error:', errorMessage);
- }
- }
- });
- }
-
// Connect to default relays with timeout
const connectionPromises = config.defaultRelays.map(async (url) => {
try {
- // Add timeout to each connection attempt
await Promise.race([
this.addRelay(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), 10000)
)
]);
- console.log(`Connected to relay: ${url}`);
} catch (error) {
- console.warn(`Failed to connect to relay ${url}:`, error);
+ // Silently fail - we'll retry later if needed
}
});
- // Wait for all connection attempts (don't fail if some fail)
await Promise.allSettled(connectionPromises);
-
- const connectedCount = this.relays.size;
- console.log(`Initialized with ${connectedCount}/${config.defaultRelays.length} relays connected`);
-
this.initialized = true;
}
- /**
- * Add a relay connection
- */
async addRelay(url: string): Promise {
if (this.relays.has(url)) return;
-
try {
const relay = await Relay.connect(url);
this.relays.set(url, relay);
} catch (error) {
- console.error(`Failed to connect to relay ${url}:`, error);
throw error;
}
}
- /**
- * Remove a relay connection
- */
async removeRelay(url: string): Promise {
const relay = this.relays.get(url);
if (relay) {
try {
relay.close();
} catch (error) {
- // Ignore errors when closing
+ // Ignore
}
this.relays.delete(url);
}
}
- /**
- * Check if a relay is still connected and remove it if closed
- */
private checkAndCleanupRelay(relayUrl: string): boolean {
const relay = this.relays.get(relayUrl);
if (!relay) return false;
-
- // Check relay status: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED
const status = (relay as any).status;
if (status === 3) {
- // Relay is closed, remove it
this.relays.delete(relayUrl);
return false;
}
-
return true;
}
- /**
- * Check if a zap receipt should be filtered (below threshold)
- */
private shouldFilterZapReceipt(event: NostrEvent): boolean {
- if (event.kind !== 9735) return false; // Not a zap receipt
-
+ if (event.kind !== 9735) return false;
const amountTag = event.tags.find((t) => t[0] === 'amount');
- if (!amountTag || !amountTag[1]) return true; // Filter if no amount tag
-
+ if (!amountTag || !amountTag[1]) return true;
const amount = parseInt(amountTag[1], 10);
- if (isNaN(amount)) return true; // Filter if invalid amount
-
- // Filter if amount is below threshold
- return amount < config.zapThreshold;
+ return isNaN(amount) || amount < config.zapThreshold;
}
- /**
- * Add event to cache
- */
private addToCache(event: NostrEvent): void {
- // Filter out low-value zap receipts before caching
- if (this.shouldFilterZapReceipt(event)) {
- return; // Don't cache spam zap receipts
- }
-
- // Cache to IndexedDB
- cacheEvent(event).catch((error) => {
- console.error('Error caching event:', error);
+ if (this.shouldFilterZapReceipt(event)) return;
+ cacheEvent(event).catch(() => {
+ // Silently fail
});
}
- /**
- * Get events from cache that match filters
- */
private async getCachedEvents(filters: Filter[]): Promise {
try {
const results: NostrEvent[] = [];
const seen = new Set();
- // Query IndexedDB for each filter
for (const filter of filters) {
try {
if (filter.kinds && filter.kinds.length === 1) {
@@ -179,22 +135,16 @@ class NostrClient {
}
}
} catch (error) {
- // If cache access fails for a specific filter, log and continue
- console.debug('Error accessing cache for filter:', error);
+ // Continue with other filters
}
}
return filterEvents(results);
} catch (error) {
- // If cache is completely unavailable, return empty array
- console.debug('Cache unavailable, returning empty results:', error);
return [];
}
}
- /**
- * Publish an event to relays
- */
async publish(event: NostrEvent, options: PublishOptions = {}): Promise<{
success: string[];
failed: Array<{ relay: string; error: string }>;
@@ -205,14 +155,11 @@ class NostrClient {
failed: [] as Array<{ relay: string; error: string }>
};
- // Add event to cache first
this.addToCache(event);
- // Publish to each relay
for (const url of relays) {
const relay = this.relays.get(url);
if (!relay) {
- // Try to connect if not already connected
try {
await this.addRelay(url);
const newRelay = this.relays.get(url);
@@ -230,7 +177,7 @@ class NostrClient {
} catch (error) {
results.failed.push({
relay: url,
- error: error instanceof Error ? error.message : 'Failed to connect'
+ error: 'Failed to connect'
});
}
} else {
@@ -249,9 +196,6 @@ class NostrClient {
return results;
}
- /**
- * Subscribe to events
- */
subscribe(
filters: Filter[],
relays: string[],
@@ -260,47 +204,32 @@ class NostrClient {
): string {
const subId = `sub_${this.nextSubId++}_${Date.now()}`;
- // Filter to only active relays
- const activeRelays = relays.filter(url => this.relays.has(url));
-
for (const url of relays) {
- // Skip if relay is not in pool (will try to reconnect below)
if (!this.relays.has(url)) {
- // Try to connect if not already connected
this.addRelay(url).then(() => {
const newRelay = this.relays.get(url);
if (newRelay) {
this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose);
}
- }).catch((error) => {
- console.debug(`Failed to connect to relay ${url}:`, error);
+ }).catch(() => {
+ // Silently fail
});
continue;
}
const relay = this.relays.get(url);
- if (!relay) continue; // Double-check (shouldn't happen, but safety check)
+ if (!relay) continue;
- // Try to subscribe, handle errors if relay is closed
try {
this.setupSubscription(relay, url, subId, filters, onEvent, onEose);
} catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) {
- console.debug(`Relay ${url} is closed, removing from pool`);
- this.relays.delete(url);
- } else {
- console.error(`Error subscribing to relay ${url}:`, error);
- }
+ // Handle errors
}
}
return subId;
}
- /**
- * Setup a subscription on a relay
- */
private setupSubscription(
relay: Relay,
url: string,
@@ -309,72 +238,37 @@ class NostrClient {
onEvent: (event: NostrEvent, relay: string) => void,
onEose?: (relay: string) => void
): void {
- // Check if relay is still in the pool (might have been removed due to close)
- if (!this.relays.has(url)) {
- console.warn(`Relay ${url} not in pool, skipping subscription`);
- return;
- }
+ if (!this.relays.has(url)) return;
- // Wrap subscription in try-catch and handle both sync and async errors
- try {
- const client = this;
- const sub = relay.subscribe(filters, {
- onevent(event: NostrEvent) {
- try {
- // Check if relay is still in pool before processing
- if (!client.relays.has(url)) return;
- // Filter out low-value zap receipts
- if (client.shouldFilterZapReceipt(event)) return;
- // Add to cache
- client.addToCache(event);
- // Call callback
- onEvent(event, url);
- } catch (err) {
- console.error(`Error handling event from relay ${url}:`, err);
- }
- },
- oneose() {
- try {
- // Check if relay is still in pool before processing
- if (!client.relays.has(url)) return;
- onEose?.(url);
- } catch (err) {
- console.error(`Error handling EOSE from relay ${url}:`, err);
- }
+ try {
+ const client = this;
+ const sub = relay.subscribe(filters, {
+ onevent: (event: NostrEvent) => {
+ try {
+ if (!client.relays.has(url)) return;
+ if (client.shouldFilterZapReceipt(event)) return;
+ client.addToCache(event);
+ onEvent(event, url);
+ } catch (err) {
+ // Silently handle errors
}
- });
-
- // Wrap subscription in a promise to catch async errors
- Promise.resolve(sub).catch((err) => {
- const errorMessage = err instanceof Error ? err.message : String(err);
- if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) {
- console.warn(`Relay ${url} subscription error (closed connection), removing from pool`);
- this.relays.delete(url);
- // Clean up this subscription
- this.subscriptions.delete(`${url}_${subId}`);
- } else {
- console.error(`Relay ${url} subscription error:`, err);
+ },
+ oneose: () => {
+ try {
+ if (!client.relays.has(url)) return;
+ onEose?.(url);
+ } catch (err) {
+ // Silently handle errors
}
- });
+ }
+ });
- this.subscriptions.set(`${url}_${subId}`, { relay, sub });
- } catch (error) {
- // Handle any other errors gracefully
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) {
- console.warn(`Relay ${url} connection is closed, removing from pool`);
- this.relays.delete(url);
- return;
- } else {
- console.error(`Error setting up subscription on relay ${url}:`, error);
- return;
- }
+ this.subscriptions.set(`${url}_${subId}`, { relay, sub });
+ } catch (error) {
+ // Handle errors
}
}
- /**
- * Unsubscribe
- */
unsubscribe(subId: string): void {
for (const [key, { sub }] of this.subscriptions.entries()) {
if (key.endsWith(`_${subId}`)) {
@@ -384,61 +278,158 @@ class NostrClient {
}
}
- /**
- * Fetch events
- */
+ // Throttled request to a relay
+ private async throttledRelayRequest(
+ relayUrl: string,
+ filters: Filter[],
+ events: Map,
+ timeout: number
+ ): Promise {
+ return new Promise((resolve) => {
+ const makeRequest = () => {
+ const now = Date.now();
+ const lastRequest = this.lastRequestTime.get(relayUrl) || 0;
+ const timeSinceLastRequest = now - lastRequest;
+ const activeForRelay = this.activeRequestsPerRelay.get(relayUrl) || 0;
+
+ // Check if we can make the request now
+ if (timeSinceLastRequest >= this.MIN_REQUEST_INTERVAL &&
+ activeForRelay < this.MAX_CONCURRENT_PER_RELAY &&
+ this.totalActiveRequests < this.MAX_CONCURRENT_TOTAL) {
+
+ // Update tracking
+ this.lastRequestTime.set(relayUrl, now);
+ this.activeRequestsPerRelay.set(relayUrl, activeForRelay + 1);
+ this.totalActiveRequests++;
+
+ // Make the request
+ this.makeRelayRequest(relayUrl, filters, events, timeout)
+ .finally(() => {
+ const current = this.activeRequestsPerRelay.get(relayUrl) || 0;
+ if (current > 0) {
+ this.activeRequestsPerRelay.set(relayUrl, current - 1);
+ }
+ if (this.totalActiveRequests > 0) {
+ this.totalActiveRequests--;
+ }
+ resolve();
+ this.processQueue(); // Process next in queue
+ });
+ } else {
+ // Wait and retry
+ const waitTime = Math.max(
+ this.MIN_REQUEST_INTERVAL - timeSinceLastRequest,
+ 100
+ );
+ setTimeout(makeRequest, waitTime);
+ }
+ };
+
+ makeRequest();
+ });
+ }
+
+ private async makeRelayRequest(
+ relayUrl: string,
+ filters: Filter[],
+ events: Map,
+ timeout: number
+ ): Promise {
+ const relay = this.relays.get(relayUrl);
+ if (!relay || !this.checkAndCleanupRelay(relayUrl)) {
+ return;
+ }
+
+ const subId = `sub_${this.nextSubId++}_${Date.now()}`;
+ let resolved = false;
+ let timeoutId: ReturnType | null = null;
+
+ const finish = () => {
+ if (resolved) return;
+ resolved = true;
+ if (timeoutId) clearTimeout(timeoutId);
+ this.unsubscribe(subId);
+ };
+
+ try {
+ const client = this;
+ const sub = relay.subscribe(filters, {
+ onevent: (event: NostrEvent) => {
+ if (!client.relays.has(relayUrl)) return;
+ if (shouldHideEvent(event)) return;
+ if (client.shouldFilterZapReceipt(event)) return;
+ events.set(event.id, event);
+ client.addToCache(event);
+ },
+ oneose: () => {
+ if (!resolved) finish();
+ }
+ });
+
+ this.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub });
+
+ timeoutId = setTimeout(() => {
+ if (!resolved) finish();
+ }, timeout);
+ } catch (error) {
+ finish();
+ }
+ }
+
+ private processQueue(): void {
+ if (this.processingQueue || this.requestQueue.length === 0) return;
+ this.processingQueue = true;
+
+ const next = this.requestQueue.shift();
+ if (next) {
+ next();
+ }
+
+ this.processingQueue = false;
+ }
+
async fetchEvents(
filters: Filter[],
relays: string[],
- options?: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout?: number }
+ options: FetchOptions = {}
): Promise {
- const { useCache = true, cacheResults = true, onUpdate } = options || {};
+ const { useCache = true, cacheResults = true, onUpdate, timeout = 10000 } = options;
- // Create a key for this fetch to prevent duplicate concurrent requests
+ // Create a key for this fetch to prevent duplicates
const fetchKey = JSON.stringify({ filters, relays: relays.sort() });
- // Check if there's already an active fetch for this combination
const activeFetch = this.activeFetches.get(fetchKey);
if (activeFetch) {
- // Return the existing promise to prevent duplicate requests
return activeFetch;
}
- // Query from cache first if enabled
+ // Query cache first
if (useCache) {
try {
const cachedEvents = await this.getCachedEvents(filters);
-
if (cachedEvents.length > 0) {
- // Return cached events immediately
- // Don't call onUpdate here - only call it when fresh data arrives
- // This prevents duplicate updates that cause feed jumping
-
- // Fetch fresh data in background (only if cacheResults is true)
- // Add a delay to prevent immediate background refresh that might cause rate limiting
+ // Return cached immediately, fetch fresh in background with delay
if (cacheResults) {
setTimeout(() => {
- // Use a different key for background refresh to allow it to run
- const bgFetchKey = `${fetchKey}_bg_${Date.now()}`;
- const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout });
- this.activeFetches.set(bgFetchKey, bgPromise);
+ const bgKey = `${fetchKey}_bg_${Date.now()}`;
+ const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout });
+ this.activeFetches.set(bgKey, bgPromise);
bgPromise.finally(() => {
- this.activeFetches.delete(bgFetchKey);
- }).catch((error) => {
- console.error('Error fetching fresh events from relays:', error);
+ this.activeFetches.delete(bgKey);
+ }).catch(() => {
+ // Silently fail
});
- }, 1000); // Delay background refresh by 1 second to reduce concurrent requests
+ }, 2000); // 2 second delay for background refresh
}
-
return cachedEvents;
}
} catch (error) {
- console.error('Error loading from cache:', error);
+ // Continue to fetch from relays
}
}
// Fetch from relays
- const fetchPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout });
+ const fetchPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout });
this.activeFetches.set(fetchKey, fetchPromise);
fetchPromise.finally(() => {
this.activeFetches.delete(fetchKey);
@@ -446,23 +437,16 @@ class NostrClient {
return fetchPromise;
}
-
- /**
- * Fetch events from relays - one request per relay with all filters, sent in parallel
- */
private async fetchFromRelays(
filters: Filter[],
relays: string[],
- options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout?: number }
+ options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout: number }
): Promise {
- const timeout = options.timeout || config.relayTimeout; // Default 10 seconds
- const client = this;
+ const timeout = options.timeout || config.relayTimeout;
- // Filter to only connected relays
let availableRelays = relays.filter(url => this.relays.has(url));
if (availableRelays.length === 0) {
- // Try to connect to relays if none are connected
await Promise.allSettled(relays.map(url => this.addRelay(url).catch(() => null)));
availableRelays = relays.filter(url => this.relays.has(url));
if (availableRelays.length === 0) {
@@ -470,147 +454,63 @@ class NostrClient {
}
}
- // Create one subscription per relay with all filters, sent in parallel
+ // Process relays sequentially with throttling to avoid overload
const events: Map = new Map();
- const relayPromises = availableRelays.map((relayUrl) => {
- return new Promise((resolve) => {
- const relay = client.relays.get(relayUrl);
- if (!relay) {
- resolve();
- return;
- }
-
- // Check if relay connection is still open, remove if closed
- if (!client.checkAndCleanupRelay(relayUrl)) {
- resolve();
- return;
- }
-
- const subId = `sub_${client.nextSubId++}_${Date.now()}`;
- let resolved = false;
- let timeoutId: ReturnType | null = null;
-
- const finish = () => {
- if (resolved) return;
- resolved = true;
- if (timeoutId) clearTimeout(timeoutId);
- client.unsubscribe(subId);
- resolve();
- };
-
- try {
- const sub = relay.subscribe(filters, {
- onevent(event: NostrEvent) {
- if (!client.relays.has(relayUrl)) return;
- if (shouldHideEvent(event)) return;
- // Filter out low-value zap receipts before adding to results
- if (client.shouldFilterZapReceipt(event)) return;
- events.set(event.id, event);
- client.addToCache(event);
- },
- oneose() {
- if (!resolved) {
- finish();
- }
- }
- });
-
- client.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub });
-
- // Timeout after specified duration
- timeoutId = setTimeout(() => {
- if (!resolved) {
- finish();
- }
- }, timeout);
- } catch (error: any) {
- // Handle errors during subscription creation
- if (error && (error.message?.includes('closed') || error.message?.includes('SendingOnClosedConnection'))) {
- // Relay closed, remove it
- client.relays.delete(relayUrl);
- } else {
- console.warn(`Error subscribing to relay ${relayUrl}:`, error);
- }
- finish();
- }
- });
- });
-
- // Wait for all relay requests to complete (or timeout)
- await Promise.allSettled(relayPromises);
+
+ for (const relayUrl of availableRelays) {
+ await this.throttledRelayRequest(relayUrl, filters, events, timeout);
+ // Small delay between relays
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
const eventArray = Array.from(events.values());
const filtered = filterEvents(eventArray);
-
- // Filter out low-value zap receipts before caching
const zapFiltered = filtered.filter(event => !this.shouldFilterZapReceipt(event));
- // Cache results in background (only non-spam zap receipts)
if (options.cacheResults && zapFiltered.length > 0) {
- cacheEvents(zapFiltered).catch((error) => {
- console.error('Error caching events:', error);
+ cacheEvents(zapFiltered).catch(() => {
+ // Silently fail
});
}
- // Call onUpdate callback (with zap-filtered results)
- if (options.onUpdate) {
- options.onUpdate(zapFiltered);
+ if (options.onUpdate && filtered.length > 0) {
+ options.onUpdate(filtered);
}
- return zapFiltered;
+ return filtered;
}
-
- /**
- * Get event by ID
- */
async getEventById(id: string, relays: string[]): Promise {
- // Try IndexedDB cache first
try {
const dbEvent = await getEvent(id);
if (dbEvent) return dbEvent;
} catch (error) {
- console.error('Error loading from IndexedDB:', error);
+ // Continue to fetch from relays
}
- // Fetch from relays
const filters: Filter[] = [{ ids: [id] }];
const events = await this.fetchEvents(filters, relays, { useCache: false });
return events[0] || null;
}
- /**
- * Get events by filters (from cache only)
- */
async getByFilters(filters: Filter[]): Promise {
return this.getCachedEvents(filters);
}
- /**
- * Get config
- */
getConfig() {
return config;
}
- /**
- * Get connected relays
- */
getConnectedRelays(): string[] {
return Array.from(this.relays.keys());
}
- /**
- * Close all connections
- */
close(): void {
- // Close all subscriptions
for (const { sub } of this.subscriptions.values()) {
sub.close();
}
this.subscriptions.clear();
- // Close all relay connections
for (const relay of this.relays.values()) {
relay.close();
}
diff --git a/svelte.config.js b/svelte.config.js
index 38aac47..78a7d3d 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -4,6 +4,11 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
+ compilerOptions: {
+ compatibility: {
+ componentApi: 4 // Enable Svelte 4 component API for dynamic mounting
+ }
+ },
kit: {
adapter: adapter({
pages: 'build',