Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
96b62106fe
  1. 4
      public/healthz.json
  2. 9
      src/app.css
  3. 34
      src/lib/components/content/MarkdownRenderer.svelte
  4. 46
      src/lib/components/content/MediaAttachments.svelte
  5. 2
      src/lib/components/content/QuotedContext.svelte
  6. 2
      src/lib/components/content/ReplyContext.svelte
  7. 2
      src/lib/components/content/mount-component-action.ts
  8. 5
      src/lib/components/layout/Header.svelte
  9. 4
      src/lib/components/layout/ProfileBadge.svelte
  10. 22
      src/lib/components/preferences/UserPreferences.svelte
  11. 20
      src/lib/modules/comments/CommentThread.svelte
  12. 772
      src/lib/modules/feed/FeedPage.svelte
  13. 108
      src/lib/modules/feed/FeedPost.svelte
  14. 158
      src/lib/modules/feed/ReplaceableEventCard.svelte
  15. 24
      src/lib/modules/feed/ThreadDrawer.svelte
  16. 2
      src/lib/modules/profiles/ProfilePage.svelte
  17. 388
      src/lib/modules/reactions/FeedReactionButtons.svelte
  18. 44
      src/lib/modules/threads/ThreadList.svelte
  19. 86
      src/lib/services/cache/event-cache.ts
  20. 44
      src/lib/services/cache/indexeddb-store.ts
  21. 36
      src/lib/services/cache/profile-cache.ts
  22. 492
      src/lib/services/nostr/nostr-client.ts
  23. 5
      svelte.config.js

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -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
}

9
src/app.css

@ -132,13 +132,14 @@ img[src*="emoji" i] { @@ -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);
}

34
src/lib/components/content/MarkdownRenderer.svelte

@ -147,7 +147,7 @@ @@ -147,7 +147,7 @@
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} 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 @@ @@ -162,14 +162,14 @@
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} 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 = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} 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 = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
@ -178,8 +178,8 @@ @@ -178,8 +178,8 @@
const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, parsed.data); // Store the bech32 string
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
}
} catch {
@ -229,7 +229,7 @@ @@ -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 @@ @@ -237,7 +237,7 @@
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} 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 @@ @@ -251,13 +251,13 @@
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} 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 = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} 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 = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
@ -265,11 +265,11 @@ @@ -265,11 +265,11 @@
const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, parsed.data);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
}
} catch {
}
} catch {
// Fallback to generic link if decoding fails
if (parsed.type === 'npub' || parsed.type === 'nprofile') {
try {

46
src/lib/components/content/MediaAttachments.svelte

@ -206,12 +206,12 @@ @@ -206,12 +206,12 @@
{#if coverImage}
<div class="cover-image mb-4">
{#if shouldLoad(coverImage.url)}
<img
src={coverImage.url}
alt=""
class="w-full max-h-96 object-cover rounded"
loading="lazy"
/>
<img
src={coverImage.url}
alt=""
class="w-full max-h-96 object-cover rounded"
loading="lazy"
/>
{:else}
<div
class="media-placeholder w-full max-h-96 bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
@ -231,12 +231,12 @@ @@ -231,12 +231,12 @@
{#if item.type === 'image'}
<div class="media-item">
{#if shouldLoad(item.url)}
<img
src={item.url}
alt=""
class="max-w-full rounded"
loading="lazy"
/>
<img
src={item.url}
alt=""
class="max-w-full rounded"
loading="lazy"
/>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
@ -251,18 +251,18 @@ @@ -251,18 +251,18 @@
{:else if item.type === 'video'}
<div class="media-item">
{#if shouldLoad(item.url)}
<video
src={item.url}
controls
<video
src={item.url}
controls
preload="none"
class="max-w-full rounded"
style="max-height: 500px;"
class="max-w-full rounded"
style="max-height: 500px;"
autoplay={false}
muted={false}
>
<track kind="captions" />
Your browser does not support the video tag.
</video>
>
<track kind="captions" />
Your browser does not support the video tag.
</video>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
@ -284,8 +284,8 @@ @@ -284,8 +284,8 @@
class="w-full"
autoplay={false}
>
Your browser does not support the audio tag.
</audio>
Your browser does not support the audio tag.
</audio>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"

2
src/lib/components/content/QuotedContext.svelte

@ -47,7 +47,7 @@ @@ -47,7 +47,7 @@
if (events.length > 0) {
loadedQuotedEvent = events[0];
if (onQuotedLoaded) {
if (onQuotedLoaded && typeof onQuotedLoaded === 'function') {
onQuotedLoaded(loadedQuotedEvent);
}
// After loading, try to scroll to it

2
src/lib/components/content/ReplyContext.svelte

@ -47,7 +47,7 @@ @@ -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

2
src/lib/components/content/mount-component-action.ts

@ -12,7 +12,7 @@ export function mountComponent( @@ -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)({

5
src/lib/components/layout/Header.svelte

@ -40,8 +40,6 @@ @@ -40,8 +40,6 @@
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Threads</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a>
{#if isLoggedIn && currentPubkey}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Logged in as </span>
<ProfileBadge pubkey={currentPubkey} />
<button
onclick={() => sessionManager.clearSession()}
class="px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text transition-colors"
@ -53,6 +51,9 @@ @@ -53,6 +51,9 @@
{/if}
<UserPreferences />
<ThemeToggle />
{#if isLoggedIn && currentPubkey}
<ProfileBadge pubkey={currentPubkey} />
{/if}
</div>
</div>
</nav>

4
src/lib/components/layout/ProfileBadge.svelte

@ -149,10 +149,6 @@ @@ -149,10 +149,6 @@
line-height: 1;
}
.activity-dot {
display: inline-block;
}
.status-text {
display: inline-block;
}

22
src/lib/components/preferences/UserPreferences.svelte

@ -77,11 +77,11 @@ @@ -77,11 +77,11 @@
<button
onclick={() => (showPreferences = !showPreferences)}
class="preferences-toggle"
class="px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors"
title="User Preferences"
aria-label="User Preferences"
>
Preferences
<span class="emoji"></span>
</button>
{#if showPreferences}
@ -192,20 +192,10 @@ @@ -192,20 +192,10 @@
{/if}
<style>
.preferences-toggle {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .preferences-toggle {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
.emoji {
font-size: 1rem;
line-height: 1;
opacity: 0.7;
}
.preferences-modal {

20
src/lib/modules/comments/CommentThread.svelte

@ -125,13 +125,13 @@ @@ -125,13 +125,13 @@
const nestedReplies = await nostrClient.fetchEvents(
nestedFilters,
allRelays,
{ useCache: true, cacheResults: true }
);
{ useCache: true, cacheResults: true }
);
// Add new replies by type
for (const reply of nestedReplies) {
if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) {
comments.push(reply);
comments.push(reply);
hasNewReplies = true;
} else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) {
kind1Replies.push(reply);
@ -139,9 +139,9 @@ @@ -139,9 +139,9 @@
} else if (reply.kind === 1244 && !yakBacks.some(y => y.id === reply.id)) {
yakBacks.push(reply);
hasNewReplies = true;
}
}
}
}
}
}
@ -261,12 +261,12 @@ @@ -261,12 +261,12 @@
if (parentId) {
// Check if parent is the thread or another reply we have
if (parentId === threadId || allEventIds.has(parentId)) {
// This is a reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
} else {
// This is a reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
} else {
// Parent not found - treat as root item (might be a missing parent)
rootItems.push(item);
}

772
src/lib/modules/feed/FeedPage.svelte

@ -1,707 +1,209 @@ @@ -1,707 +1,209 @@
<script lang="ts">
import FeedPost from './FeedPost.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { onMount } from 'svelte';
import FeedPost from './FeedPost.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getFeedKinds } from '../../types/kind-lookup.js';
import { onMount } from 'svelte';
let posts = $state<NostrEvent[]>([]);
let loading = $state(true);
let hasMore = $state(true);
let loadingMore = $state(false);
let newPostsCount = $state(0);
let lastPostId = $state<string | null>(null);
let showOPsOnly = $state(false);
let showResponsesToMe = $state(false);
let isLoadingFeed = false; // Guard to prevent concurrent loads
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingNewPosts = $state<NostrEvent[]>([]); // Store new posts until user clicks button
let loadedParentQuotedEvents = $state<Map<string, NostrEvent>>(new Map()); // Store loaded parent/quoted events separately (doesn't trigger feed re-render)
// Thread drawer state
let drawerOpen = $state(false);
let selectedEvent = $state<NostrEvent | null>(null);
// Get current user pubkey
let currentPubkey = $state<string | null>(sessionManager.getCurrentPubkey());
// Subscribe to session changes
$effect(() => {
const unsubscribe = sessionManager.session.subscribe(() => {
currentPubkey = sessionManager.getCurrentPubkey();
});
return unsubscribe;
});
let hasMore = $state(true);
let oldestTimestamp = $state<number | null>(null);
// Debounce updates to prevent rapid re-renders
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingUpdates: NostrEvent[] = [];
onMount(() => {
nostrClient.initialize().then(() => {
loadFeed();
});
(async () => {
await nostrClient.initialize();
await loadFeed();
})();
// Set up intersection observer for infinite scroll
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
loadMore();
}
}, { threshold: 0.1 });
// Set up infinite scroll
window.addEventListener('scroll', handleScroll);
const sentinel = document.getElementById('feed-sentinel');
if (sentinel) {
observer.observe(sentinel);
}
return () => {
window.removeEventListener('scroll', handleScroll);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
scrollTimeout = null;
if (sentinel) {
observer.unobserve(sentinel);
}
if (updateTimeout) {
clearTimeout(updateTimeout);
}
};
});
async function loadFeed(reset = true) {
// Prevent concurrent loads
if (isLoadingFeed) {
return;
}
isLoadingFeed = true;
if (reset) {
loading = true;
posts = [];
hasMore = true;
} else {
loadingMore = true;
}
async function loadFeed() {
loading = true;
try {
const oldestTimestamp = posts.length > 0
? Math.min(...posts.map(p => p.created_at))
: undefined;
const config = nostrClient.getConfig();
const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds();
// Phase 1: Fetch all feed kinds - one request per relay, sent in parallel
// Update cache in background (10 second timeout), view updates when cache is done
const feedFilter = feedKinds.length > 0 ? [{
kinds: feedKinds,
limit: 100,
...(oldestTimestamp ? { until: oldestTimestamp } : {})
}] : [];
let allFeedEvents: NostrEvent[] = [];
if (feedFilter.length > 0) {
// Debounce onUpdate to prevent rapid-fire updates
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingUpdate: NostrEvent[] | null = null;
// Capture reset value to avoid stale closure
const isReset = reset;
const debouncedOnUpdate = (updated: NostrEvent[]) => {
pendingUpdate = updated;
if (updateTimeout) {
clearTimeout(updateTimeout);
}
updateTimeout = setTimeout(() => {
if (!pendingUpdate) return;
const updated = pendingUpdate;
pendingUpdate = null;
// Don't update the feed automatically - only store new posts for manual update
// This prevents feed jumping and allows user to control when to refresh
// Works the same whether drawer is open or closed - events go to pending arrays
// NEVER update the feed automatically from onUpdate callback
// This prevents feed jumping - user must click button to see updates
// Only store new posts in pending arrays (works for both drawer open and closed)
if (!isReset) {
// Store new posts in pending arrays instead of updating feed automatically
const existingIds = new Set([...posts, ...pendingNewPosts].map(p => p.id));
const newPosts = updated.filter(e => !existingIds.has(e.id) && feedKinds.includes(e.kind));
if (newPosts.length > 0) {
// Add to pending posts instead of directly to feed
const existingPendingIds = new Set(pendingNewPosts.map(p => p.id));
const trulyNewPosts = newPosts.filter(e => !existingPendingIds.has(e.id));
if (trulyNewPosts.length > 0) {
pendingNewPosts = [...pendingNewPosts, ...trulyNewPosts];
// Update counter for new posts (only if drawer is closed, to avoid confusion)
if (!drawerOpen) {
if (lastPostId) {
const newCount = trulyNewPosts.filter(e => e.id !== lastPostId).length;
if (newCount > 0) {
newPostsCount += newCount;
}
} else {
// If no lastPostId, count all new posts
newPostsCount += trulyNewPosts.length;
}
}
}
}
}
}, 1000); // Debounce to 1 second to reduce update frequency
};
// For initial load (reset), don't use onUpdate to prevent incremental updates
// Wait for the full fetch to complete before displaying anything
const fetchOptions = reset
? {
useCache: true,
cacheResults: true,
timeout: 10000
// No onUpdate during initial load to prevent scrolling
}
: {
useCache: true,
cacheResults: true,
timeout: 10000,
onUpdate: debouncedOnUpdate
};
// Get cached events first for immediate display, then refresh in background
// For initial load, wait for full fetch without onUpdate to prevent scrolling
const cachedEvents = await nostrClient.fetchEvents(
feedFilter,
relays,
fetchOptions
);
// Process cached events
// Load ALL feed events into posts array (including replies and kind 1111)
// Filtering happens client-side in getFilteredPosts() based on showOPsOnly checkbox
const allFeedEvents = cachedEvents.filter((e: NostrEvent) => feedKinds.includes(e.kind));
if (reset) {
// For initial load, batch all updates at once to prevent scrolling
// Load ALL events into posts array - filtering happens client-side
// Only sort if we have posts to prevent unnecessary re-renders
if (allFeedEvents.length > 0) {
posts = sortPosts(allFeedEvents);
lastPostId = posts[0].id;
}
} else {
// For infinite scroll (loading more), add new posts directly to feed
// This is a user-initiated action, so update immediately
// BUT: Don't update if drawer is open - freeze the feed
if (!drawerOpen) {
// Don't re-sort existing posts - just append new ones to prevent jumping
const existingIds = new Set(posts.map(p => p.id));
const newPosts = allFeedEvents.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
// Sort only the new posts, then append to existing (preserve existing order)
const sortedNewPosts = sortPosts(newPosts);
posts = [...posts, ...sortedNewPosts];
}
}
}
}
// For initial load, wait a moment to ensure all data is processed before showing feed
// This prevents scrolling issues from incremental updates
if (reset) {
// Small delay to ensure DOM is ready and prevent scroll jumping
await new Promise(resolve => setTimeout(resolve, 100));
}
// Background refresh is handled automatically by fetchEvents with useCache: true
// For initial load, we don't use onUpdate, so background refresh won't cause scrolling
// Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
// Only fetch if we're not in a loading state to prevent excessive requests
// Don't fetch during initial load to prevent scrolling
if (!isLoadingFeed && !loading && !loadingMore && !reset) {
const displayedEventIds = posts.map(e => e.id);
if (displayedEventIds.length > 0) {
// Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events
const secondaryFilter = [{
kinds: [7, 9735],
'#e': displayedEventIds.slice(0, 100) // Limit to avoid huge requests
}];
// Fetch in background, update cache, view will update automatically via cache
// Don't use onUpdate here to prevent triggering UI updates that cause jumping
nostrClient.fetchEvents(
secondaryFilter,
relays,
{
useCache: true,
cacheResults: true,
timeout: 10000
}
).catch(err => {
console.error('Error fetching secondary events:', err);
});
// Load initial feed - cache first, then background refresh
const filters = [{ kinds: [1], limit: 20 }];
const events = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: true,
cacheResults: true,
onUpdate: handleUpdate,
timeout: 10000
}
);
// Phase 3: Fetch kind 0 profiles, statuses, and activity data for npubs in feed
// Batch fetch all at once to prevent individual requests from ProfileBadge components
// This runs on both initial load and background refresh
const uniquePubkeys = new Set<string>();
for (const event of posts) {
uniquePubkeys.add(event.pubkey);
}
// Sort by created_at descending
const sorted = events.sort((a, b) => b.created_at - a.created_at);
posts = sorted;
if (uniquePubkeys.size > 0) {
const pubkeysArray = Array.from(uniquePubkeys).slice(0, 100); // Limit to avoid huge requests
// Batch fetch profiles (kind 0)
const profileFilter = [{
kinds: [0],
authors: pubkeysArray
}];
// Batch fetch user statuses (kind 30315)
const statusFilter = [{
kinds: [30315],
authors: pubkeysArray,
'#d': ['general'],
limit: 1
} as any];
// Batch fetch activity data (recent events from each pubkey)
const activityFilter = [{
authors: pubkeysArray,
kinds: [0, 1, 7, 11, 1111],
limit: 1
}];
// Fetch all in parallel, update cache, view will update automatically via cache
// Don't use onUpdate here to prevent triggering UI updates that cause jumping
// Run in background to not block initial render
Promise.all([
nostrClient.fetchEvents(profileFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
}),
nostrClient.fetchEvents(statusFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
}),
nostrClient.fetchEvents(activityFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
})
]).catch(err => {
console.error('Error fetching profiles/statuses/activity:', err);
});
if (sorted.length > 0) {
oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
}
// Fetch missing parent events (batch, one request per relay)
const allEventIds = new Set(posts.map(e => e.id));
const missingParentIds = new Set<string>();
for (const event of posts) {
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag?.[1];
if (parentId && !allEventIds.has(parentId)) {
missingParentIds.add(parentId);
}
}
if (missingParentIds.size > 0) {
const parentIdsArray = Array.from(missingParentIds).slice(0, 50);
// Don't use onUpdate here to prevent triggering UI updates that cause jumping
nostrClient.fetchEvents(
[{ kinds: [1], ids: parentIdsArray }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
).catch(err => {
console.error('Error fetching parent events:', err);
});
}
}
hasMore = allFeedEvents.length >= 100;
hasMore = events.length >= 20;
} catch (error) {
console.error('Error loading feed:', error);
} finally {
loading = false;
loadingMore = false;
isLoadingFeed = false;
}
}
function handleScroll() {
// Throttle scroll handler to prevent doom loops
if (scrollTimeout) {
return;
}
scrollTimeout = setTimeout(() => {
scrollTimeout = null;
}, 500); // Increase throttle to 500ms to reduce frequency
if (loadingMore || !hasMore || isLoadingFeed) return;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
async function loadMore() {
if (loadingMore || !hasMore) return;
// Load more when within 500px of bottom (increased threshold to reduce triggers)
if (scrollTop + windowHeight >= documentHeight - 500) {
loadFeed(false);
}
}
function sortPosts(events: NostrEvent[]): NostrEvent[] {
// Build thread structure
const eventMap = new Map<string, NostrEvent>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootEvents: NostrEvent[] = [];
// First pass: build maps
for (const event of events) {
eventMap.set(event.id, event);
// Check if this is a reply
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag?.[1];
loadingMore = true;
try {
const config = nostrClient.getConfig();
const relays = relayManager.getFeedReadRelays();
if (parentId && eventMap.has(parentId)) {
// This is a reply to an event we have
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
const filters = [{
kinds: [1],
limit: 20,
until: oldestTimestamp || undefined
}];
const events = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: true,
cacheResults: true,
timeout: 10000
}
replyMap.get(parentId)!.push(event.id);
} else {
// This is a root event (not a reply, or parent not loaded)
rootEvents.push(event);
}
}
// Second pass: recursively collect all events in thread order
const result: NostrEvent[] = [];
const processed = new Set<string>();
function addThread(event: NostrEvent) {
if (processed.has(event.id)) return;
processed.add(event.id);
result.push(event);
// Add all replies to this event
const replies = replyMap.get(event.id) || [];
const replyEvents = replies
.map(id => eventMap.get(id))
.filter((e): e is NostrEvent => e !== undefined)
.sort((a, b) => a.created_at - b.created_at); // Sort replies chronologically
);
for (const reply of replyEvents) {
addThread(reply);
if (events.length === 0) {
hasMore = false;
return;
}
}
// Add all root events sorted by newest first
rootEvents.sort((a, b) => b.created_at - a.created_at);
for (const root of rootEvents) {
addThread(root);
}
return result;
}
function handleShowNewPosts() {
// Don't update feed while drawer is open - freeze in place
if (drawerOpen) return;
// Filter out duplicates
const existingIds = new Set(posts.map(p => p.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
// Merge pending new posts into the feed
if (pendingNewPosts.length > 0) {
// Sort the merged array to maintain proper order
const merged = [...pendingNewPosts, ...posts];
posts = sortPosts(merged);
pendingNewPosts = [];
}
// Scroll to top and reset new posts count
window.scrollTo({ top: 0, behavior: 'smooth' });
newPostsCount = 0;
lastPostId = posts.length > 0 ? posts[0].id : null;
}
function isReply(post: NostrEvent): boolean {
// Check if this event references another event (reply, quote, or replaceable event reference)
// Filter out anything with "e", "a", or "q" tags
if (newEvents.length > 0) {
const sorted = newEvents.sort((a, b) => b.created_at - a.created_at);
posts = [...posts, ...sorted];
// Check for "e" tag (references another event - reply)
if (post.tags.some((t) => t[0] === 'e' && t[1] !== undefined && t[1] !== post.id)) {
return true;
}
// Check for "a" tag (references a replaceable event)
if (post.tags.some((t) => t[0] === 'a' && t[1] !== undefined)) {
return true;
}
const oldest = Math.min(...newEvents.map(e => e.created_at));
if (oldest < (oldestTimestamp || Infinity)) {
oldestTimestamp = oldest;
}
}
// Check for "q" tag (quotes another event)
if (post.tags.some((t) => t[0] === 'q' && t[1] !== undefined)) {
return true;
hasMore = events.length >= 20;
} catch (error) {
console.error('Error loading more:', error);
} finally {
loadingMore = false;
}
return false;
}
/**
* Filter posts client-side based on showOPsOnly and showResponsesToMe checkboxes.
* This is purely reactive - no queries are triggered.
* All events are already loaded into the posts array from cache.
*/
function getFilteredPosts(): NostrEvent[] {
let filtered = posts;
// Filter for responses to me (events where current user is in "p" tag)
if (showResponsesToMe && currentPubkey) {
filtered = filtered.filter(post => {
// Check if current user's pubkey is in any "p" tag
return post.tags.some((t) => t[0] === 'p' && t[1] === currentPubkey);
});
}
// Debounced update handler to prevent rapid re-renders
function handleUpdate(updated: NostrEvent[]) {
pendingUpdates.push(...updated);
// Filter for OPs only (original posts, no replies, quotes, or references)
if (showOPsOnly) {
filtered = filtered.filter(post => {
// Filter out all kind 1111 events (comments)
if (post.kind === 1111) return false;
// Filter out any event that is a reply, quote, or reference
if (isReply(post)) return false;
// Keep everything else (original posts)
return true;
});
if (updateTimeout) {
clearTimeout(updateTimeout);
}
return filtered;
}
// Batch updates every 500ms
updateTimeout = setTimeout(() => {
if (pendingUpdates.length === 0) return;
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements (but allow the wrapper itself)
if (e) {
const target = e.target as HTMLElement;
const wrapper = target.closest('.post-wrapper');
const existingIds = new Set(posts.map(p => p.id));
const newEvents = pendingUpdates.filter(e => !existingIds.has(e.id));
// If the target itself is a button, link, or has role="button" (but not the wrapper)
if (target.tagName === 'BUTTON' || target.tagName === 'A' ||
(target.getAttribute('role') === 'button' && target !== wrapper)) {
return;
if (newEvents.length > 0) {
// Merge and sort
const merged = [...posts, ...newEvents];
const sorted = merged.sort((a, b) => b.created_at - a.created_at);
posts = sorted;
}
// Check if clicking inside a button or link (but not the wrapper)
const button = target.closest('button');
const link = target.closest('a');
if ((button && button !== wrapper) || (link && link !== wrapper)) {
return;
}
}
selectedEvent = event;
drawerOpen = true;
pendingUpdates = [];
}, 500);
}
function closeThreadDrawer() {
drawerOpen = false;
selectedEvent = null;
// Events that arrived while drawer was open are already in pendingNewPosts
// Update the counter now that drawer is closed so user sees the notification
if (pendingNewPosts.length > 0 && lastPostId) {
const newCount = pendingNewPosts.filter(e => e.id !== lastPostId).length;
if (newCount > 0) {
newPostsCount = newCount;
}
} else if (pendingNewPosts.length > 0) {
newPostsCount = pendingNewPosts.length;
}
}
// Use $derived to make this reactive and prevent infinite loops
let allFeedItems = $derived.by(() => {
const items: Array<{ id: string; event: NostrEvent; created_at: number }> = [];
// Add filtered posts (all events are in posts array now)
const filteredPosts = getFilteredPosts();
for (const post of filteredPosts) {
items.push({
id: post.id,
event: post,
created_at: post.created_at
});
}
// Sort by created_at, newest first
return items.sort((a, b) => b.created_at - a.created_at);
});
</script>
<div class="Feed-feed">
<div class="feed-header mb-4">
<h1 class="text-2xl font-bold mb-4">Feed</h1>
<div class="feed-controls flex flex-wrap items-center gap-2 sm:gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={showOPsOnly}
class="checkbox"
/>
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show OPs only</span>
</label>
{#if currentPubkey}
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={showResponsesToMe}
class="checkbox"
/>
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show responses to me</span>
</label>
{/if}
</div>
</div>
<div class="feed-page">
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p>
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p>
</div>
{:else if posts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet. Be the first to post!</p>
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No posts found. Check back later!</p>
</div>
{:else}
{#if newPostsCount > 0}
<div class="new-posts-indicator mb-4">
<button
onclick={handleShowNewPosts}
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 text-sm"
>
{newPostsCount} new {newPostsCount === 1 ? 'post' : 'posts'} - Click to view
</button>
</div>
{/if}
<div class="posts-list">
{#each allFeedItems as item (item.id)}
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]}
{@const parentEvent = parentId ? (posts.find(p => p.id === parentId) || loadedParentQuotedEvents.get(parentId)) : undefined}
{@const quotedId = item.event.tags.find((t) => t[0] === 'q')?.[1]}
{@const quotedEvent = quotedId ? (posts.find(p => p.id === quotedId) || loadedParentQuotedEvents.get(quotedId)) : undefined}
<div
data-post-id={item.event.id}
class="post-wrapper"
class:keyboard-selected={false}
onclick={(e) => openThreadDrawer(item.event, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(item.event);
}
}}
>
<FeedPost
post={item.event}
parentEvent={parentEvent}
quotedEvent={quotedEvent}
onParentLoaded={(event) => {
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
onQuotedLoaded={(event) => {
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
/>
</div>
<div class="feed-posts">
{#each posts as post (post.id)}
<FeedPost post={post} />
{/each}
</div>
{#if loadingMore}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p>
{/if}
{#if !hasMore && allFeedItems.length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p>
{/if}
{#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">
{#if showResponsesToMe && showOPsOnly}
No original posts responding to you found. Try unchecking the filters.
{:else if showResponsesToMe}
No responses to you found. Try unchecking "Show responses to me".
{:else}
No original posts found. Try unchecking "Show OPs only".
{/if}
</p>
{/if}
<div id="feed-sentinel" class="feed-sentinel">
{#if loadingMore}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more...</p>
{:else if hasMore}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Scroll for more</p>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No more posts</p>
{/if}
</div>
{/if}
</div>
<ThreadDrawer
opEvent={selectedEvent}
isOpen={drawerOpen}
onClose={closeThreadDrawer}
/>
<style>
.Feed-feed {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
.feed-page {
max-width: 100%;
}
.feed-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
.loading-state,
.empty-state {
padding: 2rem;
text-align: center;
}
.feed-controls {
.feed-posts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem; /* row-gap column-gap for better wrapping */
}
@media (max-width: 768px) {
.feed-controls {
gap: 0.5rem 0.75rem; /* Smaller gaps on mobile */
}
}
.checkbox {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.post-wrapper {
transition: background 0.2s;
cursor: pointer;
}
.post-wrapper:hover {
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .post-wrapper:hover {
background: var(--fog-dark-highlight, #374151);
}
.post-wrapper.keyboard-selected {
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
padding: 0.25rem;
margin: -0.25rem;
flex-direction: column;
gap: 1rem;
}
:global(.dark) .post-wrapper.keyboard-selected {
background: var(--fog-dark-highlight, #374151);
.feed-sentinel {
padding: 2rem;
text-align: center;
min-height: 100px;
}
</style>

108
src/lib/modules/feed/FeedPost.svelte

@ -192,67 +192,67 @@ @@ -192,67 +192,67 @@
</div>
{:else}
<!-- Full mode: show complete content -->
<div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply()}
<ReplyContext
parentEvent={parentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
targetId={parentEvent ? `event-${parentEvent.id}` : undefined}
onParentLoaded={onParentLoaded}
/>
{/if}
<div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply()}
<ReplyContext
parentEvent={parentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
targetId={parentEvent ? `event-${parentEvent.id}` : undefined}
onParentLoaded={onParentLoaded}
/>
{/if}
{#if hasQuotedEvent()}
<QuotedContext
quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
onQuotedLoaded={onQuotedLoaded}
/>
{/if}
{#if hasQuotedEvent()}
<QuotedContext
quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
onQuotedLoaded={onQuotedLoaded}
/>
{/if}
<div class="post-header flex items-center gap-2 mb-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if}
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">↳ Reply</span>
{/if}
</div>
<div class="post-header flex items-center gap-2 mb-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if}
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">↳ Reply</span>
{/if}
</div>
<div class="post-content mb-2">
<div class="post-content mb-2">
<MediaAttachments event={post} />
<MarkdownRenderer content={post.content} />
</div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
<FeedReactionButtons event={post} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
<MarkdownRenderer content={post.content} />
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
<FeedReactionButtons event={post} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
{/if}
</article>

158
src/lib/modules/feed/ReplaceableEventCard.svelte

@ -1,158 +0,0 @@ @@ -1,158 +0,0 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
function getDTag(): string | null {
const dTag = event.tags.find((t) => t[0] === 'd');
return dTag?.[1] || null;
}
function getWikistrUrl(): string | null {
const dTag = getDTag();
if (!dTag) return null;
return `https://wikistr.imwald.eu/${dTag}*${event.pubkey}`;
}
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - event.created_at;
const hours = Math.floor(diff / 3600);
const days = Math.floor(diff / 86400);
const minutes = Math.floor(diff / 60);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
function getClientName(): string | null {
const clientTag = event.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
</script>
<article class="replaceable-event-card">
<div class="card-header flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<ProfileBadge pubkey={event.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
</div>
</div>
<div class="card-content mb-2">
{#if getDTag()}
<div class="d-tag-display mb-2">
<span class="text-sm font-semibold text-fog-text dark:text-fog-dark-text">d-tag:</span>
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light ml-1">{getDTag()}</span>
</div>
{/if}
{#if event.content}
<div class="content-preview text-sm text-fog-text dark:text-fog-dark-text mb-2">
{event.content.slice(0, 200)}{event.content.length > 200 ? '...' : ''}
</div>
{/if}
</div>
<div class="card-actions">
{#if getWikistrUrl()}
<a
href={getWikistrUrl()}
target="_blank"
rel="noopener noreferrer"
class="wikistr-link inline-flex items-center gap-2 px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 text-sm"
>
<span>View on wikistr</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{/if}
</div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(event.kind).number}</span>
<span class="kind-description">{getKindInfo(event.kind).description}</span>
</div>
</article>
<style>
.replaceable-event-card {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
border-left: 3px solid var(--fog-accent, #64748b);
}
:global(.dark) .replaceable-event-card {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-left-color: var(--fog-dark-accent, #64748b);
}
.card-header {
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .card-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.content-preview {
line-height: 1.6;
}
.wikistr-link {
text-decoration: none;
transition: opacity 0.2s;
}
.wikistr-link:hover {
opacity: 0.9;
}
.kind-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.125rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
}
.kind-number {
font-weight: 600;
}
.kind-description {
font-size: 0.5rem;
opacity: 0.8;
}
.replaceable-event-card {
position: relative;
}
</style>

24
src/lib/modules/feed/ThreadDrawer.svelte

@ -145,10 +145,10 @@ @@ -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 @@ @@ -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 @@ @@ -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

2
src/lib/modules/profiles/ProfilePage.svelte

@ -280,7 +280,7 @@ @@ -280,7 +280,7 @@
} finally {
// Ensure loading is always set to false
if (loading) {
loading = false;
loading = false;
}
}
}

388
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -8,7 +8,7 @@ @@ -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 @@ @@ -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<Map<string, string>>(new Map()); // Map of :shortcode: -> image URL
let menuPosition = $state<'above' | 'below'>('below');
let customEmojiUrls = $state<Map<string, string>>(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 @@ @@ -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 @@ @@ -105,7 +79,6 @@
processReactions(updated);
}}
);
processReactions(reactionEvents);
} catch (error) {
console.error('Error loading reactions:', error);
@ -120,7 +93,6 @@ @@ -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 @@ @@ -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 @@ @@ -145,7 +115,6 @@
}
if (userReaction === content) {
// Remove reaction
userReaction = null;
const reaction = reactions.get(content);
if (reaction) {
@ -191,22 +160,17 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 = '';
}
}
}
@ -313,24 +258,16 @@ @@ -313,24 +258,16 @@
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 @@ @@ -338,7 +275,6 @@
</script>
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
<!-- Heart button - always visible, opens menu -->
<div class="reaction-wrapper">
<button
bind:this={menuButton}
@ -347,13 +283,11 @@ @@ -347,13 +283,11 @@
title="Like or choose reaction"
aria-label="Like or choose reaction"
>
{heartCount > 0 ? heartCount : ''}
</button>
<!-- Reaction menu dropdown -->
{#if showMenu}
{#if isMobile}
<!-- Mobile backdrop -->
<div
class="mobile-drawer-backdrop"
onclick={() => { showMenu = false; emojiSearchQuery = ''; }}
@ -369,8 +303,11 @@ @@ -369,8 +303,11 @@
aria-label="Close emoji menu"
></div>
{/if}
<div class="reaction-menu" class:menu-below={menuPosition === 'below'} class:mobile-drawer={isMobile}>
<!-- Search box - always visible at top -->
<div
class="reaction-menu"
class:menu-below={menuPosition === 'below'}
class:mobile-drawer={isMobile}
>
<div class="emoji-search-container">
<input
type="text"
@ -379,100 +316,94 @@ @@ -379,100 +316,94 @@
oninput={handleSearchInput}
class="emoji-search-input"
aria-label="Search emojis"
use:focusOnMount={!isMobile}
/>
</div>
<!-- Scrollable content area -->
<div class="reaction-menu-content">
<div class="reaction-menu-grid">
{#each filteredReactionMenu as reaction}
{@const count = getReactionCount(reaction)}
<button
onclick={() => {
toggleReaction(reaction);
showMenu = false;
}}
class="reaction-menu-item {userReaction === reaction ? 'active' : ''}"
title={reaction === '+' ? 'Like' : `React with ${getReactionDisplay(reaction)}`}
>
{#if isCustomEmoji(reaction)}
{@const url = getCustomEmojiUrl(reaction)}
{#if url}
<img src={url} alt={reaction} class="custom-emoji-img" />
<div class="reaction-menu-grid">
{#each filteredReactionMenu as reaction}
{@const count = getReactionCount(reaction)}
<button
onclick={() => {
toggleReaction(reaction);
showMenu = false;
}}
class="reaction-menu-item {userReaction === reaction ? 'active' : ''}"
title={reaction === '+' ? 'Like' : `React with ${getReactionDisplay(reaction)}`}
>
{#if isCustomEmoji(reaction)}
{@const url = getCustomEmojiUrl(reaction)}
{#if url}
<img src={url} alt={reaction} class="custom-emoji-img" />
{:else}
{reaction}
{/if}
{:else}
{reaction}
{getReactionDisplay(reaction)}
{/if}
{:else}
{getReactionDisplay(reaction)}
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
<!-- Custom emojis section -->
{#if filteredCustomEmojis.length > 0}
<div class="custom-emojis-section">
<div class="custom-emojis-label">Custom</div>
<div class="reaction-menu-grid">
{#each filteredCustomEmojis as emoji}
{@const count = getReactionCount(emoji)}
<button
onclick={() => {
toggleReaction(emoji);
showMenu = false;
}}
class="reaction-menu-item {userReaction === emoji ? 'active' : ''}"
title={`React with ${emoji}`}
>
{#if isCustomEmoji(emoji)}
{@const url = getCustomEmojiUrl(emoji)}
{#if url}
<img src={url} alt={emoji} class="custom-emoji-img" />
{#if filteredCustomEmojis.length > 0}
<div class="custom-emojis-section">
<div class="custom-emojis-label">Custom</div>
<div class="reaction-menu-grid">
{#each filteredCustomEmojis as emoji}
{@const count = getReactionCount(emoji)}
<button
onclick={() => {
toggleReaction(emoji);
showMenu = false;
}}
class="reaction-menu-item {userReaction === emoji ? 'active' : ''}"
title={`React with ${emoji}`}
>
{#if isCustomEmoji(emoji)}
{@const url = getCustomEmojiUrl(emoji)}
{#if url}
<img src={url} alt={emoji} class="custom-emoji-img" />
{:else}
{emoji}
{/if}
{:else}
{emoji}
{/if}
{:else}
{emoji}
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
{/if}
</div>
<!-- Display all other reactions that have counts > 0 (except + which is shown as heart) -->
{#each getAllReactions() as { content, count }}
{#if content !== '+'}
<button
onclick={() => toggleReaction(content)}
class="reaction-btn {userReaction === content ? 'active' : ''}"
title={`React with ${content}`}
aria-label={`React with ${content}`}
>
{#if isCustomEmoji(content)}
{@const url = getCustomEmojiUrl(content)}
{#if url}
<img src={url} alt={content} class="custom-emoji-img" />
{:else}
{content}
{/if}
<span
class="reaction-display {userReaction === content ? 'active' : ''}"
title={content === '+' ? 'Liked' : `Reacted with ${content}`}
>
{#if content === '+'}
{:else if isCustomEmoji(content)}
{@const url = getCustomEmojiUrl(content)}
{#if url}
<img src={url} alt={content} class="custom-emoji-img" />
{:else}
{content}
{/if}
{count}
</button>
{/if}
{:else}
{content}
{/if}
<span class="reaction-count-text">{count}</span>
</span>
{/each}
</div>
@ -481,17 +412,6 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -566,7 +479,6 @@
flex: 1;
}
/* Mobile drawer styles */
.reaction-menu.mobile-drawer {
position: fixed;
bottom: 0;
@ -579,7 +491,7 @@ @@ -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 @@ @@ -592,7 +504,6 @@
}
}
/* Backdrop for mobile drawer */
.mobile-drawer-backdrop {
position: fixed;
top: 0;
@ -613,10 +524,6 @@ @@ -613,10 +524,6 @@
}
}
.reaction-menu.mobile-drawer {
z-index: 1000;
}
.reaction-menu.menu-below {
bottom: auto;
top: 100%;
@ -624,40 +531,10 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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;
}
.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);
}
/* Adjust grid for mobile */
@media (max-width: 768px) {
.reaction-menu-grid {
grid-template-columns: repeat(8, 1fr);

44
src/lib/modules/threads/ThreadList.svelte

@ -97,22 +97,22 @@ @@ -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<string, NostrEvent[]>();
@ -147,17 +147,17 @@ @@ -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;
: 0;
const lastActivity = Math.max(
event.created_at,
lastCommentTime,
lastReactionTime
);
const lastActivity = Math.max(
event.created_at,
lastCommentTime,
lastReactionTime
);
return { event, lastActivity };
return { event, lastActivity };
});
return activeSorted
@ -172,8 +172,8 @@ @@ -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<string, NostrEvent[]>();
@ -190,10 +190,10 @@ @@ -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

86
src/lib/services/cache/event-cache.ts vendored

@ -14,12 +14,12 @@ export interface CachedEvent extends NostrEvent { @@ -14,12 +14,12 @@ export interface CachedEvent extends NostrEvent {
*/
export async function cacheEvent(event: NostrEvent): Promise<void> {
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<void> { @@ -31,16 +31,16 @@ export async function cacheEvent(event: NostrEvent): Promise<void> {
*/
export async function cacheEvents(events: NostrEvent[]): Promise<void> {
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<void> { @@ -52,7 +52,7 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> {
*/
export async function getEvent(id: string): Promise<CachedEvent | undefined> {
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<CachedEvent | undefined> { @@ -65,21 +65,21 @@ export async function getEvent(id: string): Promise<CachedEvent | undefined> {
*/
export async function getEventsByKind(kind: number, limit?: number): Promise<CachedEvent[]> {
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<Cac @@ -91,21 +91,21 @@ export async function getEventsByKind(kind: number, limit?: number): Promise<Cac
*/
export async function getEventsByPubkey(pubkey: string, limit?: number): Promise<CachedEvent[]> {
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 [];

44
src/lib/services/cache/indexeddb-store.ts vendored

@ -36,30 +36,30 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -36,30 +36,30 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
if (dbInstance) return dbInstance;
try {
dbInstance = await openDB<DatabaseSchema>(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<DatabaseSchema>(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<IDBPDatabase<DatabaseSchema>> { @@ -120,7 +120,7 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
});
}
return dbInstance;
return dbInstance;
} catch (error) {
console.error('Failed to open IndexedDB:', error);
// Reset instance so we can retry

36
src/lib/services/cache/profile-cache.ts vendored

@ -17,13 +17,13 @@ export interface CachedProfile { @@ -17,13 +17,13 @@ export interface CachedProfile {
export async function cacheProfile(event: NostrEvent): Promise<void> {
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<void> { @@ -35,7 +35,7 @@ export async function cacheProfile(event: NostrEvent): Promise<void> {
*/
export async function getProfile(pubkey: string): Promise<CachedProfile | undefined> {
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<CachedProfile | undefi @@ -48,19 +48,19 @@ export async function getProfile(pubkey: string): Promise<CachedProfile | undefi
*/
export async function getProfiles(pubkeys: string[]): Promise<Map<string, CachedProfile>> {
try {
const db = await getDB();
const profiles = new Map<string, CachedProfile>();
const tx = db.transaction('profiles', 'readonly');
const db = await getDB();
const profiles = new Map<string, CachedProfile>();
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();

492
src/lib/services/nostr/nostr-client.ts

@ -1,6 +1,6 @@ @@ -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 { @@ -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<string, Relay> = new Map();
private subscriptions: Map<string, { relay: Relay; sub: any }> = new Map();
private nextSubId = 1;
private activeFetches: Map<string, Promise<NostrEvent[]>> = new Map(); // Track active fetches to prevent duplicates
private activeFetches: Map<string, Promise<NostrEvent[]>> = new Map();
// Rate limiting and throttling
private requestQueue: Array<() => void> = [];
private processingQueue = false;
private lastRequestTime: Map<string, number> = new Map(); // relay -> timestamp
private activeRequestsPerRelay: Map<string, number> = 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<void> {
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<void> {
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<void> {
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<NostrEvent[]> {
try {
const results: NostrEvent[] = [];
const seen = new Set<string>();
// Query IndexedDB for each filter
for (const filter of filters) {
try {
if (filter.kinds && filter.kinds.length === 1) {
@ -179,22 +135,16 @@ class NostrClient { @@ -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 { @@ -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 { @@ -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 { @@ -249,9 +196,6 @@ class NostrClient {
return results;
}
/**
* Subscribe to events
*/
subscribe(
filters: Filter[],
relays: string[],
@ -260,47 +204,32 @@ class NostrClient { @@ -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 { @@ -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 { @@ -384,61 +278,158 @@ class NostrClient {
}
}
/**
* Fetch events
*/
// Throttled request to a relay
private async throttledRelayRequest(
relayUrl: string,
filters: Filter[],
events: Map<string, NostrEvent>,
timeout: number
): Promise<void> {
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<string, NostrEvent>,
timeout: number
): Promise<void> {
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<typeof setTimeout> | 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<NostrEvent[]> {
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 { @@ -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<NostrEvent[]> {
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 { @@ -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<string, NostrEvent> = new Map();
const relayPromises = availableRelays.map((relayUrl) => {
return new Promise<void>((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<typeof setTimeout> | 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<NostrEvent | null> {
// 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<NostrEvent[]> {
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();
}

5
svelte.config.js

@ -4,6 +4,11 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; @@ -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',

Loading…
Cancel
Save