diff --git a/public/healthz.json b/public/healthz.json index c646a13..72d0ad1 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-03T11:48:59.137Z", + "buildTime": "2026-02-03T14:14:13.893Z", "gitCommit": "unknown", - "timestamp": 1770119339137 + "timestamp": 1770128053893 } \ No newline at end of file diff --git a/src/app.css b/src/app.css index 71209d9..3852975 100644 --- a/src/app.css +++ b/src/app.css @@ -132,13 +132,14 @@ img[src*="emoji" i] { } /* Apply grayscale filter to reaction buttons containing emojis */ -.reaction-btn, -.Feed-reaction-buttons button { +/* But exclude emoji menu items - they should be full color */ +.reaction-btn:not(.reaction-menu-item), +.Feed-reaction-buttons button:not(.reaction-menu-item) { filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); } -.dark .reaction-btn, -.dark .Feed-reaction-buttons button { +.dark .reaction-btn:not(.reaction-menu-item), +.dark .Feed-reaction-buttons button:not(.reaction-menu-item) { filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); } diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 24ef176..061932a 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -147,7 +147,7 @@ embeddedEvents.set(eventPlaceholder, eventId); replacement = `
`; } else { - const decoded: any = nip19.decode(parsed.data); + const decoded: any = nip19.decode(parsed.data); if (decoded.type === 'npub' || decoded.type === 'nprofile') { const pubkey = decoded.type === 'npub' ? String(decoded.data) @@ -162,14 +162,14 @@ } else { replacement = `${uri}`; } - } else if (decoded.type === 'note') { - const eventId = String(decoded.data); + } else if (decoded.type === 'note') { + const eventId = String(decoded.data); // Use custom element for embedded event const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); replacement = `
`; - } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { - const eventId = String(decoded.data.id); + } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { + const eventId = String(decoded.data.id); const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); replacement = `
`; @@ -178,8 +178,8 @@ const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, parsed.data); // Store the bech32 string replacement = `
`; - } else { - replacement = `${uri}`; + } else { + replacement = `${uri}`; } } } catch { @@ -229,7 +229,7 @@ for (const [placeholder, { uri, parsed }] of placeholders.entries()) { let replacement = ''; - try { + try { // Handle hexID type (no decoding needed) if (parsed.type === 'hexID') { const eventId = parsed.data; @@ -237,7 +237,7 @@ embeddedEvents.set(eventPlaceholder, eventId); replacement = `
`; } else { - const decoded: any = nip19.decode(parsed.data); + const decoded: any = nip19.decode(parsed.data); if (decoded.type === 'npub' || decoded.type === 'nprofile') { const pubkey = decoded.type === 'npub' ? String(decoded.data) @@ -251,13 +251,13 @@ } else { replacement = `${uri}`; } - } else if (decoded.type === 'note') { - const eventId = String(decoded.data); + } else if (decoded.type === 'note') { + const eventId = String(decoded.data); const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); replacement = `
`; - } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { - const eventId = String(decoded.data.id); + } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { + const eventId = String(decoded.data.id); const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); replacement = `
`; @@ -265,11 +265,11 @@ const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, parsed.data); replacement = `
`; - } else { - replacement = `${uri}`; + } else { + replacement = `${uri}`; } - } - } catch { + } + } catch { // Fallback to generic link if decoding fails if (parsed.type === 'npub' || parsed.type === 'nprofile') { try { diff --git a/src/lib/components/content/MediaAttachments.svelte b/src/lib/components/content/MediaAttachments.svelte index 0dae25e..9eee66d 100644 --- a/src/lib/components/content/MediaAttachments.svelte +++ b/src/lib/components/content/MediaAttachments.svelte @@ -206,12 +206,12 @@ {#if coverImage}
{#if shouldLoad(coverImage.url)} - + {:else}
{#if shouldLoad(item.url)} - + {:else}
{#if shouldLoad(item.url)} - + > + + Your browser does not support the video tag. + {:else}
- Your browser does not support the audio tag. - + Your browser does not support the audio tag. + {:else}
0) { loadedQuotedEvent = events[0]; - if (onQuotedLoaded) { + if (onQuotedLoaded && typeof onQuotedLoaded === 'function') { onQuotedLoaded(loadedQuotedEvent); } // After loading, try to scroll to it diff --git a/src/lib/components/content/ReplyContext.svelte b/src/lib/components/content/ReplyContext.svelte index a78f799..5292f11 100644 --- a/src/lib/components/content/ReplyContext.svelte +++ b/src/lib/components/content/ReplyContext.svelte @@ -47,7 +47,7 @@ if (events.length > 0) { loadedParentEvent = events[0]; - if (onParentLoaded) { + if (onParentLoaded && typeof onParentLoaded === 'function') { onParentLoaded(loadedParentEvent); } // After loading, try to scroll to it diff --git a/src/lib/components/content/mount-component-action.ts b/src/lib/components/content/mount-component-action.ts index d913ac7..a385678 100644 --- a/src/lib/components/content/mount-component-action.ts +++ b/src/lib/components/content/mount-component-action.ts @@ -12,7 +12,7 @@ export function mountComponent( // Mount the component if (component && typeof component === 'function') { - // For Svelte 5, we need to use the component constructor differently + // Using Svelte 4 component API (enabled via compatibility mode in svelte.config.js) try { // Create a new instance instance = new (component as any)({ diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index 992ac0b..40b8daf 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -40,8 +40,6 @@ Threads Feed {#if isLoggedIn && currentPubkey} - Logged in as -
diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte index 0a02491..efe612e 100644 --- a/src/lib/components/layout/ProfileBadge.svelte +++ b/src/lib/components/layout/ProfileBadge.svelte @@ -149,10 +149,6 @@ line-height: 1; } - .activity-dot { - display: inline-block; - } - .status-text { display: inline-block; } diff --git a/src/lib/components/preferences/UserPreferences.svelte b/src/lib/components/preferences/UserPreferences.svelte index 0dadbcb..7b485af 100644 --- a/src/lib/components/preferences/UserPreferences.svelte +++ b/src/lib/components/preferences/UserPreferences.svelte @@ -77,11 +77,11 @@ {#if showPreferences} @@ -192,20 +192,10 @@ {/if} diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index 6d3fb93..19a3783 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -192,67 +192,67 @@
{:else} -
- {#if isReply()} - - {/if} +
+ {#if isReply()} + + {/if} - {#if hasQuotedEvent()} - - {/if} + {#if hasQuotedEvent()} + + {/if} -
- - {getRelativeTime()} - {#if getClientName()} - via {getClientName()} - {/if} - {#if isReply()} - ↳ Reply - {/if} -
+
+ + {getRelativeTime()} + {#if getClientName()} + via {getClientName()} + {/if} + {#if isReply()} + ↳ Reply + {/if} +
-
+
- -
- -
- - {#if onReply} - - {/if} -
+
- - {#if needsExpansion} - - {/if} - -
- {getKindInfo(post.kind).number} - {getKindInfo(post.kind).description} + +
+ + {#if onReply} + + {/if}
+
+ + {#if needsExpansion} + + {/if} + +
+ {getKindInfo(post.kind).number} + {getKindInfo(post.kind).description} +
{/if} diff --git a/src/lib/modules/feed/ReplaceableEventCard.svelte b/src/lib/modules/feed/ReplaceableEventCard.svelte deleted file mode 100644 index 172ebe6..0000000 --- a/src/lib/modules/feed/ReplaceableEventCard.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -
-
-
- - {getRelativeTime()} - {#if getClientName()} - via {getClientName()} - {/if} -
-
- -
- {#if getDTag()} -
- d-tag: - {getDTag()} -
- {/if} - - {#if event.content} -
- {event.content.slice(0, 200)}{event.content.length > 200 ? '...' : ''} -
- {/if} -
- -
- {#if getWikistrUrl()} - - View on wikistr - - - - - {/if} -
- -
- {getKindInfo(event.kind).number} - {getKindInfo(event.kind).description} -
-
- - diff --git a/src/lib/modules/feed/ThreadDrawer.svelte b/src/lib/modules/feed/ThreadDrawer.svelte index 98ed1f7..40e50b3 100644 --- a/src/lib/modules/feed/ThreadDrawer.svelte +++ b/src/lib/modules/feed/ThreadDrawer.svelte @@ -145,10 +145,10 @@ if (allReplies.length === 0) { // Cache miss - fetch from network allReplies = await nostrClient.fetchEvents( - replyFilters, - relays, - { useCache: true, cacheResults: true } - ); + replyFilters, + relays, + { useCache: true, cacheResults: true } + ); } // Filter comments to ensure they match the thread (for threads, check #E tag and #K tag) @@ -178,10 +178,10 @@ // Only fetch from network if cache is empty if (reactionEvents.length === 0) { reactionEvents = await nostrClient.fetchEvents( - [{ kinds: [7], '#e': [eventId] }], - relays, - { useCache: true, cacheResults: true } - ); + [{ kinds: [7], '#e': [eventId] }], + relays, + { useCache: true, cacheResults: true } + ); } reactions = reactionEvents; @@ -240,10 +240,10 @@ if (nestedReplies.length === 0) { // Cache miss - fetch from network nestedReplies = await nostrClient.fetchEvents( - nestedFilters, - relays, - { useCache: true, cacheResults: true } - ); + nestedFilters, + relays, + { useCache: true, cacheResults: true } + ); } // Filter nested comments to ensure they match correctly diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index bf236e0..d061293 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -280,7 +280,7 @@ } finally { // Ensure loading is always set to false if (loading) { - loading = false; + loading = false; } } } diff --git a/src/lib/modules/reactions/FeedReactionButtons.svelte b/src/lib/modules/reactions/FeedReactionButtons.svelte index 3fb8ad8..f36b77a 100644 --- a/src/lib/modules/reactions/FeedReactionButtons.svelte +++ b/src/lib/modules/reactions/FeedReactionButtons.svelte @@ -8,7 +8,7 @@ import { resolveCustomEmojis } from '../../services/nostr/nip30-emoji.js'; interface Props { - event: NostrEvent; // Feed event + event: NostrEvent; } let { event }: Props = $props(); @@ -18,62 +18,43 @@ let loading = $state(true); let showMenu = $state(false); let menuButton: HTMLButtonElement | null = $state(null); - let menuPosition = $state<'above' | 'below'>('above'); // Track menu position - let customEmojiUrls = $state>(new Map()); // Map of :shortcode: -> image URL + let menuPosition = $state<'above' | 'below'>('below'); + let customEmojiUrls = $state>(new Map()); let emojiSearchQuery = $state(''); let isMobile = $state(false); - // Derived value for heart count let heartCount = $derived(getReactionCount('+')); - // Get emoji list from unicode-emoji-json library - // The library provides an array of emoji strings in order const reactionMenu = $derived.by(() => { - const emojis: string[] = ['+']; // Heart (default) always first - - // Add ALL emojis from the library - the menu will scroll - // The data-ordered-emoji.json is already an array of emoji strings + const emojis: string[] = ['+']; for (let i = 0; i < emojiData.length; i++) { const emoji = emojiData[i]; if (typeof emoji === 'string' && emoji.trim()) { emojis.push(emoji); } } - return emojis; }); - // Filter emojis based on search query const filteredReactionMenu = $derived.by(() => { if (!emojiSearchQuery.trim()) { return reactionMenu; } - const query = emojiSearchQuery.toLowerCase().trim(); return reactionMenu.filter(emoji => { - // Search by emoji character itself - if (emoji.toLowerCase().includes(query)) { - return true; - } - // For custom emojis, search by shortcode + if (emoji.toLowerCase().includes(query)) return true; if (emoji.startsWith(':') && emoji.endsWith(':')) { return emoji.toLowerCase().includes(query); } - // Try to match emoji unicode name (basic approach) - // This is a simple search - could be enhanced with proper emoji name data return false; }); }); - // Custom emoji reactions (like :turtlehappy_sm:) - // These will be added dynamically from actual reactions received - onMount(() => { nostrClient.initialize().then(() => { loadReactions(); }); - // Check if mobile on mount and resize checkMobile(); window.addEventListener('resize', checkMobile); @@ -83,21 +64,14 @@ }); function checkMobile() { - isMobile = window.innerWidth < 768; // Match Tailwind's md breakpoint + isMobile = window.innerWidth < 768; } async function loadReactions() { loading = true; try { const config = nostrClient.getConfig(); - - const filters = [ - { - kinds: [7], - '#e': [event.id] - } - ]; - + const filters = [{ kinds: [7], '#e': [event.id] }]; const reactionEvents = await nostrClient.fetchEvents( filters, [...config.defaultRelays], @@ -105,7 +79,6 @@ processReactions(updated); }} ); - processReactions(reactionEvents); } catch (error) { console.error('Error loading reactions:', error); @@ -120,7 +93,6 @@ for (const reactionEvent of reactionEvents) { const content = reactionEvent.content.trim(); - if (!reactionMap.has(content)) { reactionMap.set(content, { content, pubkeys: new Set() }); } @@ -132,8 +104,6 @@ } reactions = reactionMap; - - // Resolve custom emojis (NIP-30) to image URLs const emojiUrls = await resolveCustomEmojis(reactionMap); customEmojiUrls = emojiUrls; } @@ -145,7 +115,6 @@ } if (userReaction === content) { - // Remove reaction userReaction = null; const reaction = reactions.get(content); if (reaction) { @@ -191,22 +160,17 @@ } } catch (error) { console.error('Error publishing reaction:', error); - alert('Error publishing reaction'); } } function getReactionDisplay(content: string): string { if (content === '+') return '❤️'; - - // Check if this is a custom emoji with a resolved URL if (content.startsWith(':') && content.endsWith(':')) { const url = customEmojiUrls.get(content); if (url) { - // Return a placeholder that will be replaced with img tag in template - return content; // We'll render as img in template + return content; } } - return content; } @@ -223,14 +187,12 @@ } function getAllReactions(): Array<{ content: string; count: number }> { - // Get all reactions that have counts > 0, sorted by count (descending) const allReactions: Array<{ content: string; count: number }> = []; for (const [content, data] of reactions.entries()) { if (data.pubkeys.size > 0) { allReactions.push({ content, count: data.pubkeys.size }); } } - // Sort by count descending, then by content return allReactions.sort((a, b) => { if (b.count !== a.count) return b.count - a.count; return a.content.localeCompare(b.content); @@ -238,7 +200,6 @@ } function getCustomEmojis(): string[] { - // Extract custom emoji reactions (format: :name:) const customEmojis: string[] = []; for (const content of reactions.keys()) { if (content.startsWith(':') && content.endsWith(':') && !reactionMenu.includes(content)) { @@ -248,18 +209,13 @@ return customEmojis.sort(); } - // Filter custom emojis based on search query const filteredCustomEmojis = $derived.by(() => { const customEmojis = getCustomEmojis(); if (!emojiSearchQuery.trim()) { return customEmojis; } - const query = emojiSearchQuery.toLowerCase().trim(); - return customEmojis.filter(emoji => { - // Search by shortcode (e.g., :turtlehappy_sm:) - return emoji.toLowerCase().includes(query); - }); + return customEmojis.filter(emoji => emoji.toLowerCase().includes(query)); }); function closeMenuOnOutsideClick(e: MouseEvent) { @@ -268,42 +224,31 @@ !menuButton.contains(target) && !target.closest('.reaction-menu')) { showMenu = false; - emojiSearchQuery = ''; // Clear search when closing + emojiSearchQuery = ''; } } - function handleHeartClick() { + function handleHeartClick(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (showMenu) { - // If menu is open, clicking heart again should just like/unlike - toggleReaction('+'); showMenu = false; - emojiSearchQuery = ''; // Clear search when closing + emojiSearchQuery = ''; } else { - // On mobile, always use bottom drawer if (isMobile) { menuPosition = 'below'; + showMenu = true; + emojiSearchQuery = ''; } else { - // Check if there's enough space above the button if (menuButton) { const rect = menuButton.getBoundingClientRect(); const spaceAbove = rect.top; const spaceBelow = window.innerHeight - rect.bottom; - // Position below if there's more space below or if space above is less than 300px menuPosition = spaceBelow > spaceAbove || spaceAbove < 300 ? 'below' : 'above'; } - } - // If menu is closed, open it - showMenu = true; - emojiSearchQuery = ''; // Clear search when opening - - // Focus search input after menu opens (using setTimeout to ensure DOM is ready) - if (!isMobile) { - setTimeout(() => { - const searchInput = document.querySelector('.emoji-search-input') as HTMLInputElement; - if (searchInput) { - searchInput.focus(); - } - }, 0); + showMenu = true; + emojiSearchQuery = ''; } } } @@ -312,25 +257,17 @@ const target = e.target as HTMLInputElement; emojiSearchQuery = target.value; } - - // Action to focus input when menu opens - function focusOnMount(node: HTMLInputElement, shouldFocus: boolean) { - if (shouldFocus) { - setTimeout(() => node.focus(), 0); - } - return { - update(newShouldFocus: boolean) { - if (newShouldFocus) { - setTimeout(() => node.focus(), 0); - } - } - }; - } $effect(() => { if (showMenu) { - document.addEventListener('click', closeMenuOnOutsideClick); - return () => document.removeEventListener('click', closeMenuOnOutsideClick); + const timeoutId = setTimeout(() => { + document.addEventListener('click', closeMenuOnOutsideClick, true); + }, 0); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('click', closeMenuOnOutsideClick, true); + }; } }); @@ -338,7 +275,6 @@
-
- {#if showMenu} {#if isMobile} -
{ showMenu = false; emojiSearchQuery = ''; }} @@ -369,8 +303,11 @@ aria-label="Close emoji menu" >
{/if} -
- +
-
-
- {#each filteredReactionMenu as reaction} - {@const count = getReactionCount(reaction)} - - {/each} -
- - - {#if filteredCustomEmojis.length > 0} -
-
Custom
-
- {#each filteredCustomEmojis as emoji} - {@const count = getReactionCount(emoji)} - + {/each} +
+ + {#if filteredCustomEmojis.length > 0} +
+
Custom
+
+ {#each filteredCustomEmojis as emoji} + {@const count = getReactionCount(emoji)} + - {/each} + {#if count > 0} + {count} + {/if} + + {/each} +
-
- {/if} + {/if}
{/if}
- {#each getAllReactions() as { content, count }} - {#if content !== '+'} - - {/if} + {:else} + {content} + {/if} + {count} + {/each}
@@ -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',