Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
a84c3bd6b0
  1. 14
      src/app.css
  2. 92
      src/lib/components/content/EmbeddedEvent.svelte
  3. 11
      src/lib/components/content/MarkdownRenderer.svelte
  4. 56
      src/lib/modules/feed/FeedPage.svelte
  5. 25
      src/lib/modules/feed/FeedPost.svelte
  6. 18
      src/lib/modules/feed/Reply.svelte
  7. 41
      src/lib/modules/reactions/FeedReactionButtons.svelte
  8. 46
      src/lib/services/nostr/nostr-client.ts

14
src/app.css

@ -116,10 +116,12 @@ img[src*="profile" i] {
} }
/* Emoji images - grayscale like profile pics */ /* Emoji images - grayscale like profile pics */
/* Only apply filter to actual image elements, not text emojis */
.emoji, .emoji,
[class*="emoji"], [class*="emoji"],
img[alt*="emoji" i], img[alt*="emoji" i],
img[src*="emoji" i] { img[src*="emoji" i],
img.emoji-inline {
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);
display: inline-block; display: inline-block;
} }
@ -127,10 +129,18 @@ img[src*="emoji" i] {
.dark .emoji, .dark .emoji,
.dark [class*="emoji"], .dark [class*="emoji"],
.dark img[alt*="emoji" i], .dark img[alt*="emoji" i],
.dark img[src*="emoji" i] { .dark img[src*="emoji" i],
.dark img.emoji-inline {
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9);
} }
/* Ensure normal Unicode emojis (text characters) are displayed correctly */
/* Use emoji-friendly fonts and ensure they're not filtered */
body, .markdown-content, .post-content {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
/* Normal emojis are text, not images, so no filter should apply */
}
/* Apply grayscale filter to reaction buttons containing emojis */ /* Apply grayscale filter to reaction buttons containing emojis */
/* But exclude emoji menu items - they should be full color */ /* But exclude emoji menu items - they should be full color */
.reaction-btn:not(.reaction-menu-item), .reaction-btn:not(.reaction-menu-item),

92
src/lib/components/content/EmbeddedEvent.svelte

@ -16,12 +16,28 @@
let event = $state<NostrEvent | null>(null); let event = $state<NostrEvent | null>(null);
let loading = $state(true); let loading = $state(true);
let error = $state(false); let error = $state(false);
let loadingEvent = $state(false);
let lastEventId = $state<string | null>(null);
onMount(async () => { onMount(async () => {
if (eventId && eventId !== lastEventId) {
await loadEvent(); await loadEvent();
}
});
$effect(() => {
if (eventId && eventId !== lastEventId && !loadingEvent) {
lastEventId = eventId;
loadEvent();
}
}); });
async function loadEvent() { async function loadEvent() {
// Prevent concurrent loads for the same event
if (loadingEvent) {
return;
}
loadingEvent = true;
loading = true; loading = true;
error = false; error = false;
try { try {
@ -95,6 +111,7 @@
error = true; error = true;
} finally { } finally {
loading = false; loading = false;
loadingEvent = false;
} }
} }
@ -102,13 +119,10 @@
if (!event) return ''; if (!event) return '';
if (event.kind === 11) { if (event.kind === 11) {
const titleTag = event.tags.find(t => t[0] === 'title'); const titleTag = event.tags.find(t => t[0] === 'title');
return titleTag?.[1] || 'Untitled'; return titleTag?.[1] || '';
}
const firstLine = event.content.split('\n')[0].trim();
if (firstLine.length > 0 && firstLine.length < 100) {
return firstLine;
} }
return 'Event'; // For other event kinds, don't use content as title - leave it blank
return '';
} }
function getSubject(): string | null { function getSubject(): string | null {
@ -123,9 +137,38 @@
return imageTag?.[1] || null; return imageTag?.[1] || null;
} }
// Extract image URLs from event content
function getImageUrlsFromContent(): string[] {
if (!event) return [];
const imageUrls: string[] = [];
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i;
const urlPattern = /https?:\/\/[^\s<>"']+/g;
let match;
while ((match = urlPattern.exec(event.content)) !== null) {
const url = match[0];
if (imageExtensions.test(url)) {
// Check if it's not already in markdown or HTML
const before = event.content.substring(Math.max(0, match.index - 10), match.index);
const after = event.content.substring(match.index + url.length, Math.min(event.content.length, match.index + url.length + 10));
if (!before.includes('![') && !before.includes('<img') && !after.startsWith('](') && !after.startsWith('</img>')) {
imageUrls.push(url);
}
}
}
return imageUrls;
}
function getPreview(): string { function getPreview(): string {
if (!event) return ''; if (!event) return '';
const preview = stripMarkdown(event.content).slice(0, 150); // Remove image URLs from preview text
let preview = event.content;
const imageUrls = getImageUrlsFromContent();
for (const url of imageUrls) {
preview = preview.replace(url, '').trim();
}
preview = stripMarkdown(preview).slice(0, 150);
return preview.length < event.content.length ? preview + '...' : preview; return preview.length < event.content.length ? preview + '...' : preview;
} }
@ -159,10 +202,23 @@
<div class="embedded-event-image"> <div class="embedded-event-image">
<img src={getImageUrl()} alt={getTitle()} loading="lazy" /> <img src={getImageUrl()} alt={getTitle()} loading="lazy" />
</div> </div>
{:else}
{@const contentImages = getImageUrlsFromContent()}
{#if contentImages.length > 0}
<div class="embedded-event-images">
{#each contentImages.slice(0, 3) as imageUrl}
<div class="embedded-event-image">
<img src={imageUrl} alt={getTitle()} loading="lazy" />
</div>
{/each}
</div>
{/if}
{/if} {/if}
<div class="embedded-event-content"> <div class="embedded-event-content">
<div class="embedded-event-header"> <div class="embedded-event-header">
{#if getTitle()}
<h4 class="embedded-event-title">{getTitle()}</h4> <h4 class="embedded-event-title">{getTitle()}</h4>
{/if}
{#if event} {#if event}
<ProfileBadge pubkey={event.pubkey} /> <ProfileBadge pubkey={event.pubkey} />
{/if} {/if}
@ -213,6 +269,17 @@
object-fit: cover; object-fit: cover;
} }
.embedded-event-images {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.embedded-event-images .embedded-event-image {
margin-bottom: 0;
}
.embedded-event-content { .embedded-event-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -225,13 +292,24 @@
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
line-height: 1.5;
}
.embedded-event-header :global(.profile-badge) {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1.5;
} }
.embedded-event-title { .embedded-event-title {
font-weight: 600; font-weight: 600;
font-size: 1.125rem; font-size: 1.125rem;
margin: 0; margin: 0;
line-height: 1.5;
color: var(--fog-text, #1f2937); color: var(--fog-text, #1f2937);
display: inline-block;
vertical-align: middle;
} }
:global(.dark) .embedded-event-title { :global(.dark) .embedded-event-title {

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

@ -527,4 +527,15 @@
display: block; display: block;
margin: 1rem 0; margin: 1rem 0;
} }
/* Ensure normal Unicode emojis are displayed correctly */
:global(.markdown-content) {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
/* Ensure emojis are not filtered or hidden */
}
:global(.markdown-content *) {
/* Normal emojis (Unicode characters) should not have filters applied */
/* Only emoji images (custom emojis) should have the grayscale filter */
}
</style> </style>

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

@ -12,6 +12,9 @@
let hasMore = $state(true); let hasMore = $state(true);
let oldestTimestamp = $state<number | null>(null); let oldestTimestamp = $state<number | null>(null);
// Batch-loaded reactions: eventId -> reactions[]
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map());
// Drawer state for viewing parent/quoted events // Drawer state for viewing parent/quoted events
let drawerOpen = $state(false); let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null); let drawerEvent = $state<NostrEvent | null>(null);
@ -110,6 +113,8 @@
if (sorted.length > 0) { if (sorted.length > 0) {
oldestTimestamp = Math.min(...sorted.map(e => e.created_at)); oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
// Batch load reactions for all posts
await loadReactionsForPosts(sorted);
} }
hasMore = events.length >= 20; hasMore = events.length >= 20;
@ -161,6 +166,8 @@
if (oldest < (oldestTimestamp || Infinity)) { if (oldest < (oldestTimestamp || Infinity)) {
oldestTimestamp = oldest; oldestTimestamp = oldest;
} }
// Batch load reactions for new posts
await loadReactionsForPosts(sorted);
hasMore = events.length >= 20; hasMore = events.length >= 20;
} else if (events.length > 0) { } else if (events.length > 0) {
// All events were duplicates, but we got some results // All events were duplicates, but we got some results
@ -212,6 +219,53 @@
pendingUpdates = []; pendingUpdates = [];
}, 500); }, 500);
} }
// Batch load reactions for multiple posts at once
async function loadReactionsForPosts(postsToLoad: NostrEvent[]) {
if (postsToLoad.length === 0) return;
try {
const reactionRelays = relayManager.getProfileReadRelays();
const eventIds = postsToLoad.map(p => p.id);
// Batch fetch all reactions for all posts in one query
const allReactions = await nostrClient.fetchEvents(
[
{ kinds: [7], '#e': eventIds, limit: 1000 },
{ kinds: [7], '#E': eventIds, limit: 1000 }
],
reactionRelays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Group reactions by event ID
const newReactionsMap = new Map<string, NostrEvent[]>();
for (const reaction of allReactions) {
// Find which event(s) this reaction is for
const eTags = reaction.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]);
for (const tag of eTags) {
const eventId = tag[1];
if (eventIds.includes(eventId)) {
if (!newReactionsMap.has(eventId)) {
newReactionsMap.set(eventId, []);
}
newReactionsMap.get(eventId)!.push(reaction);
}
}
}
// Merge with existing reactions
for (const [eventId, reactions] of newReactionsMap.entries()) {
const existing = reactionsMap.get(eventId) || [];
const combined = [...existing, ...reactions];
// Deduplicate by reaction ID
const unique = Array.from(new Map(combined.map(r => [r.id, r])).values());
reactionsMap.set(eventId, unique);
}
} catch (error) {
console.error('[FeedPage] Error batch loading reactions:', error);
}
}
</script> </script>
<div class="feed-page"> <div class="feed-page">
@ -226,7 +280,7 @@
{:else} {:else}
<div class="feed-posts"> <div class="feed-posts">
{#each posts as post (post.id)} {#each posts as post (post.id)}
<FeedPost post={post} onOpenEvent={openDrawer} /> <FeedPost post={post} onOpenEvent={openDrawer} reactions={reactionsMap.get(post.id)} />
{/each} {/each}
</div> </div>

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

@ -461,6 +461,7 @@
</button> </button>
{/if} {/if}
<!-- Post actions (reactions, etc.) - always visible, outside collapsible content -->
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4"> <div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if post.kind === 11} {#if post.kind === 11}
<!-- Show vote counts for threads --> <!-- Show vote counts for threads -->
@ -479,7 +480,7 @@
<span class="zap-count-number">{zapCount}</span> <span class="zap-count-number">{zapCount}</span>
</span> </span>
{/if} {/if}
<FeedReactionButtons event={post} /> <FeedReactionButtons event={post} preloadedReactions={reactions} />
{#if onReply} {#if onReply}
<button <button
onclick={() => onReply(post)} onclick={() => onReply(post)}
@ -520,6 +521,10 @@
padding-right: 6rem; /* Reserve space for kind badge */ padding-right: 6rem; /* Reserve space for kind badge */
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem; margin-top: 0.5rem;
/* Ensure post actions are always visible, even when content is collapsed */
position: relative;
z-index: 1;
overflow: visible;
} }
:global(.dark) .post-actions { :global(.dark) .post-actions {
@ -608,4 +613,22 @@
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
} }
.post-header {
display: flex;
align-items: center;
line-height: 1.5;
}
.post-header :global(.profile-badge) {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1.5;
}
.post-header :global(.profile-badge span) {
line-height: 1.5;
vertical-align: middle;
}
</style> </style>

18
src/lib/modules/feed/Reply.svelte

@ -215,4 +215,22 @@
font-size: 0.625rem; font-size: 0.625rem;
opacity: 0.8; opacity: 0.8;
} }
.reply-header {
display: flex;
align-items: center;
line-height: 1.5;
}
.reply-header :global(.profile-badge) {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1.5;
}
.reply-header :global(.profile-badge span) {
line-height: 1.5;
vertical-align: baseline;
}
</style> </style>

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

@ -12,9 +12,10 @@
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
forceUpvoteDownvote?: boolean; // Force upvote/downvote mode (for kind 1111 replies to kind 11 threads) forceUpvoteDownvote?: boolean; // Force upvote/downvote mode (for kind 1111 replies to kind 11 threads)
preloadedReactions?: NostrEvent[]; // Optional pre-loaded reactions to avoid duplicate fetches
} }
let { event, forceUpvoteDownvote = false }: Props = $props(); let { event, forceUpvoteDownvote = false, preloadedReactions }: Props = $props();
let reactions = $state<Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>>(new Map()); let reactions = $state<Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>>(new Map());
let userReaction = $state<string | null>(null); let userReaction = $state<string | null>(null);
@ -66,19 +67,45 @@
}); });
}); });
let loadingReactions = $state(false);
let lastEventId = $state<string | null>(null);
onMount(() => { onMount(() => {
nostrClient.initialize().then(() => { nostrClient.initialize().then(async () => {
if (event.id) {
// Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) {
for (const r of preloadedReactions) {
allReactionsMap.set(r.id, r);
}
const filtered = await filterDeletedReactions(preloadedReactions);
processReactions(filtered);
} else {
loadReactions(); loadReactions();
}
}
}); });
}); });
// Reload reactions when event changes // Reload reactions when event changes (but prevent duplicate loads)
$effect(() => { $effect(() => {
if (event.id) { if (event.id && event.id !== lastEventId && !loadingReactions) {
lastEventId = event.id;
// Clear previous reactions map when event changes // Clear previous reactions map when event changes
allReactionsMap.clear(); allReactionsMap.clear();
// Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) {
for (const r of preloadedReactions) {
allReactionsMap.set(r.id, r);
}
filterDeletedReactions(preloadedReactions).then(filtered => {
processReactions(filtered);
});
} else {
loadReactions(); loadReactions();
} }
}
}); });
// Handle real-time updates - process reactions when new ones arrive // Handle real-time updates - process reactions when new ones arrive
@ -105,6 +132,11 @@
} }
async function loadReactions() { async function loadReactions() {
// Prevent concurrent loads for the same event
if (loadingReactions) {
return;
}
loadingReactions = true;
loading = true; loading = true;
try { try {
// Use getProfileReadRelays() to include defaultRelays + profileRelays + user inbox + localRelays // Use getProfileReadRelays() to include defaultRelays + profileRelays + user inbox + localRelays
@ -188,6 +220,7 @@
console.error('[FeedReactionButtons] Error loading reactions:', error); console.error('[FeedReactionButtons] Error loading reactions:', error);
} finally { } finally {
loading = false; loading = false;
loadingReactions = false;
} }
} }

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

@ -39,6 +39,9 @@ class NostrClient {
private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests
private totalActiveRequests = 0; private totalActiveRequests = 0;
// Track background refresh operations to prevent duplicates
private backgroundRefreshes: Set<string> = new Set();
// Failed relay tracking with exponential backoff // Failed relay tracking with exponential backoff
private failedRelays: Map<string, { lastFailure: number; retryAfter: number; failureCount: number }> = new Map(); private failedRelays: Map<string, { lastFailure: number; retryAfter: number; failureCount: number }> = new Map();
private readonly INITIAL_RETRY_DELAY = 5000; // 5 seconds private readonly INITIAL_RETRY_DELAY = 5000; // 5 seconds
@ -230,32 +233,32 @@ class NostrClient {
// Handle both uppercase and lowercase tag filters (Nostr spec allows both) // Handle both uppercase and lowercase tag filters (Nostr spec allows both)
// Ignore relays that don't support the tag filter, they need to be corrected by the relay operator. // Ignore relays that don't support the tag filter, they need to be corrected by the relay operator.
if (filter['#e'] && filter['#e'].length > 0) { if (filter['#e'] && filter['#e'].length > 0) {
const eventTags = event.tags.filter(t => t[0] === 'e' || t[0] === 'E').map(t => t[1]); const eventTags = event.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]).map(t => t[1]);
if (!filter['#e'].some(id => eventTags.includes(id))) continue; if (eventTags.length === 0 || !filter['#e'].some(id => eventTags.includes(id))) continue;
} }
if (filter['#E'] && filter['#E'].length > 0) { if (filter['#E'] && filter['#E'].length > 0) {
const eventTags = event.tags.filter(t => t[0] === 'e' || t[0] === 'E').map(t => t[1]); const eventTags = event.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]).map(t => t[1]);
if (!filter['#E'].some(id => eventTags.includes(id))) continue; if (eventTags.length === 0 || !filter['#E'].some(id => eventTags.includes(id))) continue;
} }
if (filter['#p'] && filter['#p'].length > 0) { if (filter['#p'] && filter['#p'].length > 0) {
const pubkeyTags = event.tags.filter(t => t[0] === 'p' || t[0] === 'P').map(t => t[1]); const pubkeyTags = event.tags.filter(t => (t[0] === 'p' || t[0] === 'P') && t[1]).map(t => t[1]);
if (!filter['#p'].some(pk => pubkeyTags.includes(pk))) continue; if (pubkeyTags.length === 0 || !filter['#p'].some(pk => pubkeyTags.includes(pk))) continue;
} }
if (filter['#P'] && filter['#P'].length > 0) { if (filter['#P'] && filter['#P'].length > 0) {
const pubkeyTags = event.tags.filter(t => t[0] === 'p' || t[0] === 'P').map(t => t[1]); const pubkeyTags = event.tags.filter(t => (t[0] === 'p' || t[0] === 'P') && t[1]).map(t => t[1]);
if (!filter['#P'].some(pk => pubkeyTags.includes(pk))) continue; if (pubkeyTags.length === 0 || !filter['#P'].some(pk => pubkeyTags.includes(pk))) continue;
} }
if (filter['#a'] && filter['#a'].length > 0) { if (filter['#a'] && filter['#a'].length > 0) {
const aTags = event.tags.filter(t => t[0] === 'a' || t[0] === 'A').map(t => t[1]); const aTags = event.tags.filter(t => (t[0] === 'a' || t[0] === 'A') && t[1]).map(t => t[1]);
if (!filter['#a'].some(a => aTags.includes(a))) continue; if (aTags.length === 0 || !filter['#a'].some(a => aTags.includes(a))) continue;
} }
if (filter['#A'] && filter['#A'].length > 0) { if (filter['#A'] && filter['#A'].length > 0) {
const aTags = event.tags.filter(t => t[0] === 'a' || t[0] === 'A').map(t => t[1]); const aTags = event.tags.filter(t => (t[0] === 'a' || t[0] === 'A') && t[1]).map(t => t[1]);
if (!filter['#A'].some(a => aTags.includes(a))) continue; if (aTags.length === 0 || !filter['#A'].some(a => aTags.includes(a))) continue;
} }
if (filter['#d'] && filter['#d'].length > 0) { if (filter['#d'] && filter['#d'].length > 0) {
const dTags = event.tags.filter(t => t[0] === 'd' || t[0] === 'D').map(t => t[1]); const dTags = event.tags.filter(t => (t[0] === 'd' || t[0] === 'D') && t[1]).map(t => t[1]);
if (!filter['#d'].some(d => dTags.includes(d))) continue; if (dTags.length === 0 || !filter['#d'].some(d => dTags.includes(d))) continue;
} }
// Use matchFilter for final validation // Use matchFilter for final validation
@ -568,20 +571,27 @@ class NostrClient {
// Return cached immediately, fetch fresh in background with delay // Return cached immediately, fetch fresh in background with delay
// Don't pass onUpdate to background fetch to avoid interfering with cached results // Don't pass onUpdate to background fetch to avoid interfering with cached results
if (cacheResults) { if (cacheResults) {
// Prevent duplicate background refreshes for the same filter
if (!this.backgroundRefreshes.has(fetchKey)) {
this.backgroundRefreshes.add(fetchKey);
// Use a longer delay for background refresh to avoid interfering with initial load // Use a longer delay for background refresh to avoid interfering with initial load
setTimeout(() => { setTimeout(() => {
const bgKey = `${fetchKey}_bg_${Date.now()}`;
// Only update cache, don't call onUpdate for background refresh // Only update cache, don't call onUpdate for background refresh
const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate: undefined, timeout }); // This ensures cached events persist and are not cleared by background refresh
this.activeFetches.set(bgKey, bgPromise); const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults: true, onUpdate: undefined, timeout });
bgPromise.finally(() => { bgPromise.finally(() => {
this.activeFetches.delete(bgKey); // Remove from background refreshes set after a delay to allow re-refresh if needed
setTimeout(() => {
this.backgroundRefreshes.delete(fetchKey);
}, 60000); // Allow re-refresh after 60 seconds
}).catch((error) => { }).catch((error) => {
// Log but don't throw - background refresh failures shouldn't affect cached results // Log but don't throw - background refresh failures shouldn't affect cached results
console.debug('[nostr-client] Background refresh failed:', error); console.debug('[nostr-client] Background refresh failed:', error);
this.backgroundRefreshes.delete(fetchKey);
}); });
}, 5000); // 5 second delay for background refresh to avoid interfering }, 5000); // 5 second delay for background refresh to avoid interfering
} }
}
return cachedEvents; return cachedEvents;
} else { } else {
console.log(`[nostr-client] No cached events found for filter:`, filters); console.log(`[nostr-client] No cached events found for filter:`, filters);

Loading…
Cancel
Save