Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
37fa84e5bb
  1. 17
      src/lib/components/content/MarkdownRenderer.svelte
  2. 135
      src/lib/components/content/QuotedContext.svelte
  3. 84
      src/lib/components/content/ReplyContext.svelte
  4. 546
      src/lib/modules/feed/FeedPage.svelte
  5. 39
      src/lib/modules/feed/FeedPost.svelte
  6. 440
      src/lib/modules/feed/ThreadDrawer.svelte
  7. 16
      src/lib/modules/feed/ZapReceiptReply.svelte
  8. 36
      src/lib/services/nostr/nostr-client.ts
  9. 2
      src/lib/types/kind-lookup.ts

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

@ -28,7 +28,8 @@
const sortedLinks = [...links].sort((a, b) => b.start - a.start); const sortedLinks = [...links].sort((a, b) => b.start - a.start);
for (const link of sortedLinks) { for (const link of sortedLinks) {
const placeholder = `__NIP21_LINK_${offset}__`; // Use a unique placeholder that won't be processed by markdown
const placeholder = `\u200B\u200B\u200BNIP21_LINK_${offset}\u200B\u200B\u200B`;
const before = processed.slice(0, link.start); const before = processed.slice(0, link.start);
const after = processed.slice(link.end); const after = processed.slice(link.end);
processed = before + placeholder + after; processed = before + placeholder + after;
@ -68,9 +69,14 @@
} }
} }
finalHtml = finalHtml.replace(new RegExp(placeholder, 'g'), replacement); // Escape placeholder for regex replacement
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
} }
// Clean up any remaining placeholders (fallback)
finalHtml = finalHtml.replace(/\u200B\u200B\u200BNIP21_LINK_\d+\u200B\u200B\u200B/g, '');
rendered = finalHtml; rendered = finalHtml;
}); });
} else { } else {
@ -103,9 +109,14 @@
} }
} }
finalHtml = finalHtml.replace(new RegExp(placeholder, 'g'), replacement); // Escape placeholder for regex replacement
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
} }
// Clean up any remaining placeholders (fallback)
finalHtml = finalHtml.replace(/\u200B\u200B\u200BNIP21_LINK_\d+\u200B\u200B\u200B/g, '');
rendered = finalHtml; rendered = finalHtml;
} }
} else { } else {

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

@ -0,0 +1,135 @@
<script lang="ts">
import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
interface Props {
quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId
quotedEventId?: string; // Optional - used to load quoted event if not provided
targetId?: string; // Optional ID to scroll to (defaults to quoted event ID)
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded
}
let { quotedEvent: providedQuotedEvent, quotedEventId, targetId, onQuotedLoaded }: Props = $props();
let loadedQuotedEvent = $state<NostrEvent | null>(null);
let loadingQuoted = $state(false);
// Derive the effective quoted event: prefer provided, fall back to loaded
let quotedEvent = $derived(providedQuotedEvent || loadedQuotedEvent);
// Sync provided quoted event changes and load if needed
$effect(() => {
if (providedQuotedEvent) {
// If provided quoted event is available, use it
return;
}
// If no provided quoted event and we have an ID, try to load it
if (!loadedQuotedEvent && quotedEventId && !loadingQuoted) {
loadQuotedEvent();
}
});
async function loadQuotedEvent() {
const eventId = quotedEventId || quotedEvent?.id;
if (!eventId || loadingQuoted) return;
loadingQuoted = true;
try {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [1], ids: [eventId] }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
loadedQuotedEvent = events[0];
if (onQuotedLoaded) {
onQuotedLoaded(loadedQuotedEvent);
}
// After loading, try to scroll to it
setTimeout(() => scrollToQuoted(), 100);
}
} catch (error) {
console.error('Error loading quoted event:', error);
} finally {
loadingQuoted = false;
}
}
function getQuotedPreview(): string {
if (!quotedEvent) {
return loadingQuoted ? 'Loading...' : 'Quoted event not found';
}
// Create preview from quoted event (first 100 chars, plaintext)
const plaintext = quotedEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim();
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : '');
}
async function scrollToQuoted() {
const eventId = quotedEvent?.id || quotedEventId;
if (!eventId) return;
// If quoted event not loaded yet, load it first
if (!quotedEvent && quotedEventId) {
await loadQuotedEvent();
}
const elementId = targetId || `event-${eventId}`;
let element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`);
// If still not found, wait a bit for DOM to update
if (!element) {
await new Promise(resolve => setTimeout(resolve, 200));
element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`);
}
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('highlight-quoted');
setTimeout(() => {
element?.classList.remove('highlight-quoted');
}, 2000);
}
}
</script>
<div
class="quoted-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light cursor-pointer hover:opacity-80 transition-opacity"
onclick={scrollToQuoted}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToQuoted();
}
}}
>
<span class="font-semibold">Quoting:</span> {getQuotedPreview()}
{#if loadingQuoted}
<span class="text-xs opacity-70"> (loading...)</span>
{/if}
</div>
<style>
.quoted-context {
border-left: 2px solid var(--fog-accent, #64748b);
}
:global(.dark) .quoted-context {
border-left-color: var(--fog-dark-accent, #64748b);
}
:global(.highlight-quoted) {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
transition: outline 0.3s ease;
}
:global(.dark .highlight-quoted) {
outline-color: var(--fog-dark-accent, #64748b);
}
</style>

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

@ -1,27 +1,96 @@
<script lang="ts"> <script lang="ts">
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
interface Props { interface Props {
parentEvent: NostrEvent; parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId
parentEventId?: string; // Optional - used to load parent if not provided
targetId?: string; // Optional ID to scroll to (defaults to parent event ID) targetId?: string; // Optional ID to scroll to (defaults to parent event ID)
onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded
} }
let { parentEvent, targetId }: Props = $props(); let { parentEvent: providedParentEvent, parentEventId, targetId, onParentLoaded }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false);
// Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent);
// Sync provided parent event changes and load if needed
$effect(() => {
if (providedParentEvent) {
// If provided parent event is available, use it
return;
}
// If no provided parent event and we have an ID, try to load it
if (!loadedParentEvent && parentEventId && !loadingParent) {
loadParentEvent();
}
});
async function loadParentEvent() {
const eventId = parentEventId || parentEvent?.id;
if (!eventId || loadingParent) return;
loadingParent = true;
try {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [1], ids: [eventId] }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
loadedParentEvent = events[0];
if (onParentLoaded) {
onParentLoaded(loadedParentEvent);
}
// After loading, try to scroll to it
setTimeout(() => scrollToParent(), 100);
}
} catch (error) {
console.error('Error loading parent event:', error);
} finally {
loadingParent = false;
}
}
function getParentPreview(): string { function getParentPreview(): string {
if (!parentEvent) {
return loadingParent ? 'Loading...' : 'Parent event not found';
}
// Create preview from parent (first 100 chars, plaintext) // Create preview from parent (first 100 chars, plaintext)
const plaintext = parentEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim(); const plaintext = parentEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim();
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : '');
} }
function scrollToParent() { async function scrollToParent() {
const elementId = targetId || `event-${parentEvent.id}`; const eventId = parentEvent?.id || parentEventId;
const element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${parentEvent.id}"]`); if (!eventId) return;
// If parent not loaded yet, load it first
if (!parentEvent && parentEventId) {
await loadParentEvent();
}
const elementId = targetId || `event-${eventId}`;
let element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`);
// If still not found, wait a bit for DOM to update
if (!element) {
await new Promise(resolve => setTimeout(resolve, 200));
element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`);
}
if (element) { if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' }); element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('highlight-parent'); element.classList.add('highlight-parent');
setTimeout(() => { setTimeout(() => {
element.classList.remove('highlight-parent'); element?.classList.remove('highlight-parent');
}, 2000); }, 2000);
} }
} }
@ -40,6 +109,9 @@
}} }}
> >
<span class="font-semibold">Replying to:</span> {getParentPreview()} <span class="font-semibold">Replying to:</span> {getParentPreview()}
{#if loadingParent}
<span class="text-xs opacity-70"> (loading...)</span>
{/if}
</div> </div>
<style> <style>

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

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import FeedPost from './FeedPost.svelte'; import FeedPost from './FeedPost.svelte';
import ReplaceableEventCard from './ReplaceableEventCard.svelte'; import ReplaceableEventCard from './ReplaceableEventCard.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getFeedKinds, getReplaceableKinds } from '../../types/kind-lookup.js'; import { getFeedKinds, getReplaceableKinds } from '../../types/kind-lookup.js';
@ -15,6 +17,26 @@
let newPostsCount = $state(0); let newPostsCount = $state(0);
let lastPostId = $state<string | null>(null); let lastPostId = $state<string | null>(null);
let showOPsOnly = $state(false); 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 pendingNewReplaceable = $state<NostrEvent[]>([]); // Store new replaceable events
// 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;
});
onMount(() => { onMount(() => {
nostrClient.initialize().then(() => { nostrClient.initialize().then(() => {
@ -26,10 +48,21 @@
return () => { return () => {
window.removeEventListener('scroll', handleScroll); window.removeEventListener('scroll', handleScroll);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
scrollTimeout = null;
}
}; };
}); });
async function loadFeed(reset = true) { async function loadFeed(reset = true) {
// Prevent concurrent loads
if (isLoadingFeed) {
return;
}
isLoadingFeed = true;
if (reset) { if (reset) {
loading = true; loading = true;
posts = []; posts = [];
@ -56,22 +89,105 @@
}] : []; }] : [];
let allFeedEvents: NostrEvent[] = []; let allFeedEvents: NostrEvent[] = [];
let cacheUpdatePromise: Promise<void> | null = null;
if (feedFilter.length > 0) { if (feedFilter.length > 0) {
// Get cached events first for immediate display // 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
const updatedRegularPosts = updated.filter((e: NostrEvent) => e.kind === 1);
const updatedReplaceable = updated.filter((e: NostrEvent) =>
replaceableKinds.includes(e.kind) &&
e.tags.some(t => t[0] === 'd')
);
const updatedOtherFeedEvents = updated.filter((e: NostrEvent) =>
e.kind !== 1 &&
!replaceableKinds.includes(e.kind) &&
feedKinds.includes(e.kind)
);
// 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
if (!isReset) {
// Store new posts in pending arrays instead of updating feed automatically
const existingIds = new Set([...posts, ...pendingNewPosts].map(p => p.id));
const allNewEvents = [...updatedRegularPosts, ...updatedOtherFeedEvents];
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id));
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
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;
}
}
}
// Store new replaceable events in pending array
const existingReplaceableIds = new Set([...replaceableEvents, ...pendingNewReplaceable].map(e => e.id));
const newReplaceable = updatedReplaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
pendingNewReplaceable = [...pendingNewReplaceable, ...newReplaceable];
}
}
allFeedEvents = updated;
}, 1000); // Debounce to 1 second to reduce update frequency
};
// Get cached events first for immediate display, then refresh in background
// useCache: true will automatically trigger background refresh with onUpdate
const cachedEvents = await nostrClient.fetchEvents( const cachedEvents = await nostrClient.fetchEvents(
feedFilter, feedFilter,
relays, relays,
{ useCache: true, cacheResults: false } {
useCache: true,
cacheResults: true,
timeout: 10000,
onUpdate: debouncedOnUpdate
}
); );
// Process cached events immediately // Process cached events immediately
// Load ALL feed events into posts array (including replies and kind 1111)
// Filtering happens client-side in getFilteredPosts() based on showOPsOnly checkbox
const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1); const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1);
const replaceable = cachedEvents.filter((e: NostrEvent) => const replaceable = cachedEvents.filter((e: NostrEvent) =>
replaceableKinds.includes(e.kind) && replaceableKinds.includes(e.kind) &&
e.tags.some(t => t[0] === 'd') e.tags.some(t => t[0] === 'd')
); );
// Include all other feed kinds (including kind 1111 comments)
const otherFeedEvents = cachedEvents.filter((e: NostrEvent) => const otherFeedEvents = cachedEvents.filter((e: NostrEvent) =>
e.kind !== 1 && e.kind !== 1 &&
!replaceableKinds.includes(e.kind) && !replaceableKinds.includes(e.kind) &&
@ -79,144 +195,127 @@
); );
if (reset) { if (reset) {
posts = sortPosts([...regularPosts, ...otherFeedEvents]); // Load ALL events into posts array - filtering happens client-side
// Only sort if we have posts to prevent unnecessary re-renders
if (regularPosts.length > 0 || otherFeedEvents.length > 0) {
posts = sortPosts([...regularPosts, ...otherFeedEvents]);
// Invalidate cache
cachedFeedItems = null;
}
replaceableEvents = replaceable.sort((a, b) => b.created_at - a.created_at); replaceableEvents = replaceable.sort((a, b) => b.created_at - a.created_at);
lastPostId = regularPosts.length > 0 ? regularPosts[0].id : null; lastPostId = regularPosts.length > 0 ? regularPosts[0].id : null;
} // Invalidate cache
cachedFeedItems = null;
// Fetch fresh data in background with 10 second timeout, update cache } else {
cacheUpdatePromise = nostrClient.fetchEvents( // For infinite scroll (loading more), add new posts directly to feed
feedFilter, // This is a user-initiated action, so update immediately
relays, // Don't re-sort existing posts - just append new ones to prevent jumping
{ const existingIds = new Set(posts.map(p => p.id));
useCache: false, const allNewEvents = [...regularPosts, ...otherFeedEvents];
cacheResults: true, const newPosts = allNewEvents.filter(e => !existingIds.has(e.id));
timeout: 10000,
onUpdate: (updated) => { if (newPosts.length > 0) {
const updatedRegularPosts = updated.filter((e: NostrEvent) => e.kind === 1); // Sort only the new posts, then append to existing (preserve existing order)
const updatedReplaceable = updated.filter((e: NostrEvent) => const sortedNewPosts = sortPosts(newPosts);
replaceableKinds.includes(e.kind) && posts = [...posts, ...sortedNewPosts];
e.tags.some(t => t[0] === 'd') // Invalidate cache
); cachedFeedItems = null;
const updatedOtherFeedEvents = updated.filter((e: NostrEvent) =>
e.kind !== 1 &&
!replaceableKinds.includes(e.kind) &&
feedKinds.includes(e.kind)
);
const sorted = sortPosts([...updatedRegularPosts, ...updatedOtherFeedEvents]);
if (reset) {
posts = sorted;
replaceableEvents = updatedReplaceable.sort((a, b) => b.created_at - a.created_at);
lastPostId = updatedRegularPosts.length > 0 ? updatedRegularPosts[0].id : null;
} else {
const existingIds = new Set(posts.map(p => p.id));
const newPosts = sorted.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
if (lastPostId) {
const newCount = sorted.filter(e => e.id !== lastPostId && !existingIds.has(e.id)).length;
if (newCount > 0) {
newPostsCount += newCount;
}
}
posts = sortPosts([...posts, ...newPosts]);
}
const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id));
const newReplaceable = updatedReplaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
replaceableEvents = [...replaceableEvents, ...newReplaceable].sort((a, b) => b.created_at - a.created_at);
}
}
allFeedEvents = updated;
}
} }
).then(events => {
allFeedEvents = events; const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id));
}).catch(err => { const newReplaceable = replaceable.filter(e => !existingReplaceableIds.has(e.id));
console.error('Error fetching feed events:', err); if (newReplaceable.length > 0) {
}); // Append new replaceable events without re-sorting existing ones
replaceableEvents = [...replaceableEvents, ...newReplaceable.sort((a, b) => b.created_at - a.created_at)];
// Invalidate cache
cachedFeedItems = null;
}
}
allFeedEvents = cachedEvents;
} }
// Wait for Phase 1 cache update to complete before proceeding // Background refresh is handled automatically by fetchEvents with useCache: true
if (cacheUpdatePromise) { // No need to wait for it - it updates the UI via onUpdate callback
await cacheUpdatePromise;
}
// Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events // 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) // One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id); // Only fetch if we're not in a loading state to prevent excessive requests
if (displayedEventIds.length > 0) { if (!isLoadingFeed && !loading && !loadingMore) {
// Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id);
const secondaryFilter = [{ if (displayedEventIds.length > 0) {
kinds: [7, 9735], // Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events
'#e': displayedEventIds.slice(0, 100) // Limit to avoid huge requests 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 }];
nostrClient.fetchEvents(
secondaryFilter, // Fetch in background, update cache, view will update automatically via cache
relays, // Don't use onUpdate here to prevent triggering UI updates that cause jumping
{ nostrClient.fetchEvents(
useCache: true, secondaryFilter,
cacheResults: true, relays,
timeout: 10000 {
} useCache: true,
).catch(err => { cacheResults: true,
console.error('Error fetching secondary events:', err); timeout: 10000
}); }
} ).catch(err => {
console.error('Error fetching secondary events:', err);
});
}
// Phase 3: Fetch kind 0 profiles for npubs in feed // Phase 3: Fetch kind 0 profiles for npubs in feed
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout) // One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
const uniquePubkeys = new Set<string>(); const uniquePubkeys = new Set<string>();
for (const event of [...posts, ...replaceableEvents]) { for (const event of [...posts, ...replaceableEvents]) {
uniquePubkeys.add(event.pubkey); uniquePubkeys.add(event.pubkey);
} }
if (uniquePubkeys.size > 0) { if (uniquePubkeys.size > 0) {
const profileFilter = [{ const profileFilter = [{
kinds: [0], kinds: [0],
authors: Array.from(uniquePubkeys).slice(0, 100) // Limit to avoid huge requests authors: Array.from(uniquePubkeys).slice(0, 100) // Limit to avoid huge requests
}]; }];
// Fetch in background, update cache, view will update automatically via cache // Fetch in background, update cache, view will update automatically via cache
nostrClient.fetchEvents( // Don't use onUpdate here to prevent triggering UI updates that cause jumping
profileFilter, nostrClient.fetchEvents(
relays, profileFilter,
{ relays,
useCache: true, {
cacheResults: true, useCache: true,
timeout: 10000 cacheResults: true,
} timeout: 10000
).catch(err => { }
console.error('Error fetching profiles:', err); ).catch(err => {
}); console.error('Error fetching profiles:', err);
} });
}
// Fetch missing parent events (batch, one request per relay) // Fetch missing parent events (batch, one request per relay)
const allEventIds = new Set(posts.map(e => e.id)); const allEventIds = new Set(posts.map(e => e.id));
const missingParentIds = new Set<string>(); const missingParentIds = new Set<string>();
for (const event of posts) { for (const event of posts) {
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag?.[1]; const parentId = replyTag?.[1];
if (parentId && !allEventIds.has(parentId)) { if (parentId && !allEventIds.has(parentId)) {
missingParentIds.add(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);
});
} }
}
if (missingParentIds.size > 0) {
const parentIdsArray = Array.from(missingParentIds).slice(0, 50);
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 = allFeedEvents.length >= 100;
@ -225,18 +324,28 @@
} finally { } finally {
loading = false; loading = false;
loadingMore = false; loadingMore = false;
isLoadingFeed = false;
} }
} }
function handleScroll() { function handleScroll() {
if (loadingMore || !hasMore) return; // 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 scrollTop = window.scrollY || document.documentElement.scrollTop;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight; const documentHeight = document.documentElement.scrollHeight;
// Load more when within 200px of bottom // Load more when within 500px of bottom (increased threshold to reduce triggers)
if (scrollTop + windowHeight >= documentHeight - 200) { if (scrollTop + windowHeight >= documentHeight - 500) {
loadFeed(false); loadFeed(false);
} }
} }
@ -301,6 +410,23 @@
function handleShowNewPosts() { function handleShowNewPosts() {
// 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 = [];
// Invalidate cache
cachedFeedItems = null;
}
if (pendingNewReplaceable.length > 0) {
replaceableEvents = [...pendingNewReplaceable, ...replaceableEvents].sort((a, b) => b.created_at - a.created_at);
pendingNewReplaceable = [];
// Invalidate cache
cachedFeedItems = null;
}
// Scroll to top and reset new posts count // Scroll to top and reset new posts count
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
newPostsCount = 0; newPostsCount = 0;
@ -308,18 +434,75 @@
} }
function isReply(post: NostrEvent): boolean { function isReply(post: NostrEvent): boolean {
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); // Check if this is a kind 1 event with a reply tag
if (!replyTag || !replyTag[1]) return false; if (post.kind === 1) {
// Check if the parent exists in the posts array const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
return posts.some(p => p.id === replyTag[1]); return replyTag !== undefined && replyTag[1] !== undefined;
}
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[] { function getFilteredPosts(): NostrEvent[] {
if (!showOPsOnly) return posts; let filtered = posts;
return posts.filter(post => !isReply(post));
// 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);
});
}
// Filter for OPs only (original posts, no replies)
if (showOPsOnly) {
filtered = filtered.filter(post => {
// Filter out all kind 1111 events (comments)
if (post.kind === 1111) return false;
// Filter out kind 1 events that are replies
if (isReply(post)) return false;
// Keep everything else (original posts)
return true;
});
}
return filtered;
}
// Cache the sorted items to prevent re-sorting on every render
let cachedFeedItems: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> | null = null;
let cachedFeedItemsKey = '';
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements
if (e) {
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('a') || target.closest('[role="button"]')) {
return;
}
}
selectedEvent = event;
drawerOpen = true;
}
function closeThreadDrawer() {
drawerOpen = false;
selectedEvent = null;
} }
function getAllFeedItems(): Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> { function getAllFeedItems(): Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> {
// Create a key based on posts/replaceableEvents length and filter state
const currentKey = `${posts.length}_${replaceableEvents.length}_${showOPsOnly}_${showResponsesToMe}`;
// Return cached result if nothing changed
if (cachedFeedItems && cachedFeedItemsKey === currentKey) {
return cachedFeedItems;
}
const items: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> = []; const items: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> = [];
// Add filtered posts // Add filtered posts
@ -343,8 +526,14 @@
}); });
} }
// Sort by created_at, newest first // Sort by created_at, newest first (only when cache is invalid)
return items.sort((a, b) => b.created_at - a.created_at); const sorted = items.sort((a, b) => b.created_at - a.created_at);
// Cache the result
cachedFeedItems = sorted;
cachedFeedItemsKey = currentKey;
return sorted;
} }
</script> </script>
@ -360,6 +549,16 @@
/> />
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show OPs only</span> <span class="text-sm text-fog-text dark:text-fog-dark-text">Show OPs only</span>
</label> </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> </div>
@ -383,8 +582,45 @@
{#if item.type === 'post'} {#if item.type === 'post'}
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]} {@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]}
{@const parentEvent = parentId ? posts.find(p => p.id === parentId) : undefined} {@const parentEvent = parentId ? posts.find(p => p.id === parentId) : undefined}
<div data-post-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}> {@const quotedId = item.event.tags.find((t) => t[0] === 'q')?.[1]}
<FeedPost post={item.event} parentEvent={parentEvent} /> {@const quotedEvent = quotedId ? posts.find(p => p.id === 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) => {
// Add loaded parent to posts array if not already there
// Don't re-sort - just append to prevent feed jumping
if (!posts.find(p => p.id === event.id)) {
posts = [...posts, event];
// Invalidate cache
cachedFeedItems = null;
}
}}
onQuotedLoaded={(event) => {
// Add loaded quoted event to posts array if not already there
// Don't re-sort - just append to prevent feed jumping
if (!posts.find(p => p.id === event.id)) {
posts = [...posts, event];
// Invalidate cache
cachedFeedItems = null;
}
}}
/>
</div> </div>
{:else if item.type === 'replaceable'} {:else if item.type === 'replaceable'}
<div data-event-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}> <div data-event-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}>
@ -399,12 +635,26 @@
{#if !hasMore && getAllFeedItems().length > 0} {#if !hasMore && getAllFeedItems().length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p> <p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p>
{/if} {/if}
{#if showOPsOnly && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0} {#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No original posts found. Try unchecking "Show OPs only".</p> <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} {/if}
{/if} {/if}
</div> </div>
<ThreadDrawer
opEvent={selectedEvent}
isOpen={drawerOpen}
onClose={closeThreadDrawer}
/>
<style> <style>
.Feed-feed { .Feed-feed {
max-width: var(--content-width); max-width: var(--content-width);
@ -434,6 +684,16 @@
.post-wrapper { .post-wrapper {
transition: background 0.2s; 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 { .post-wrapper.keyboard-selected {

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

@ -2,9 +2,8 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte';
import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -14,10 +13,13 @@
interface Props { interface Props {
post: NostrEvent; post: NostrEvent;
parentEvent?: NostrEvent; // Optional parent event if already loaded parentEvent?: NostrEvent; // Optional parent event if already loaded
quotedEvent?: NostrEvent; // Optional quoted event if already loaded
onReply?: (post: NostrEvent) => void; onReply?: (post: NostrEvent) => void;
onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded
} }
let { post, parentEvent: providedParentEvent, onReply }: Props = $props(); let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); let loadingParent = $state(false);
@ -87,6 +89,17 @@
return rootTag?.[1] || null; return rootTag?.[1] || null;
} }
function hasQuotedEvent(): boolean {
// Check if this event has a "q" tag (quoted event)
return post.tags.some((t) => t[0] === 'q');
}
function getQuotedEventId(): string | null {
// Find the 'q' tag (quoted event)
const quotedTag = post.tags.find((t) => t[0] === 'q');
return quotedTag?.[1] || null;
}
async function loadParentEvent() { async function loadParentEvent() {
const replyEventId = getReplyEventId(); const replyEventId = getReplyEventId();
if (!replyEventId || loadingParent) return; if (!replyEventId || loadingParent) return;
@ -141,8 +154,22 @@
<article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}> <article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}>
<div class="card-content" class:expanded bind:this={contentElement}> <div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply() && parentEvent} {#if isReply()}
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" /> <ReplyContext
{parentEvent}
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}
<div class="post-header flex items-center gap-2 mb-2"> <div class="post-header flex items-center gap-2 mb-2">
@ -162,8 +189,6 @@
<div class="post-actions flex items-center gap-4"> <div class="post-actions flex items-center gap-4">
<FeedReactionButtons event={post} /> <FeedReactionButtons event={post} />
<ZapButton event={post} />
<ZapReceipt eventId={post.id} pubkey={post.pubkey} />
{#if onReply} {#if onReply}
<button <button
onclick={() => onReply(post)} onclick={() => onReply(post)}

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

@ -0,0 +1,440 @@
<script lang="ts">
import { fade, slide } from 'svelte/transition';
import FeedPost from './FeedPost.svelte';
import ZapReceiptReply from './ZapReceiptReply.svelte';
import Comment from '../comments/Comment.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
opEvent: NostrEvent | null; // The original post/event
isOpen: boolean;
onClose: () => void;
}
let { opEvent, isOpen, onClose }: Props = $props();
let loading = $state(false);
let threadEvents = $state<NostrEvent[]>([]);
let reactions = $state<NostrEvent[]>([]);
// Load thread when drawer opens
$effect(() => {
if (isOpen && opEvent) {
loadThread();
} else {
// Reset when closed
threadEvents = [];
reactions = [];
}
});
async function loadThread() {
if (!opEvent) return;
loading = true;
try {
const relays = relayManager.getFeedReadRelays();
const eventId = opEvent.id;
// Load all replies: zap receipts (9735), yak backs (1244), kind 1 replies, kind 1111 comments
const replyFilters = [
{ kinds: [9735], '#e': [eventId] }, // Zap receipts
{ kinds: [1244], '#e': [eventId] }, // Yak backs (voice replies)
{ kinds: [1], '#e': [eventId] }, // Kind 1 replies
{ kinds: [1111], '#e': [eventId] } // Kind 1111 comments
];
// Fetch all reply types
const allReplies = await nostrClient.fetchEvents(
replyFilters,
relays,
{ useCache: true, cacheResults: true }
);
// Load reactions (kind 7) for the OP
const reactionEvents = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [eventId] }],
relays,
{ useCache: true, cacheResults: true }
);
reactions = reactionEvents;
// Recursively fetch nested replies
await fetchNestedReplies(allReplies, relays, eventId);
threadEvents = allReplies;
} catch (error) {
console.error('Error loading thread:', error);
} finally {
loading = false;
}
}
async function fetchNestedReplies(initialReplies: NostrEvent[], relays: string[], rootEventId: string) {
let hasNewReplies = true;
let iterations = 0;
const maxIterations = 10;
const allReplies = new Map<string, NostrEvent>();
// Add initial replies
for (const reply of initialReplies) {
allReplies.set(reply.id, reply);
}
while (hasNewReplies && iterations < maxIterations) {
iterations++;
hasNewReplies = false;
const replyIds = Array.from(allReplies.keys());
if (replyIds.length > 0) {
// Fetch replies to any of our replies
const nestedFilters = [
{ kinds: [9735], '#e': replyIds },
{ kinds: [1244], '#e': replyIds },
{ kinds: [1], '#e': replyIds },
{ kinds: [1111], '#e': replyIds }
];
const nestedReplies = await nostrClient.fetchEvents(
nestedFilters,
relays,
{ useCache: true, cacheResults: true }
);
for (const reply of nestedReplies) {
if (!allReplies.has(reply.id)) {
allReplies.set(reply.id, reply);
hasNewReplies = true;
}
}
}
}
threadEvents = Array.from(allReplies.values());
}
function getParentEvent(event: NostrEvent): NostrEvent | undefined {
// Find parent event in thread
const eTag = event.tags.find((t) => t[0] === 'e' && t[1] !== event.id);
if (eTag && eTag[1]) {
// Check if parent is the OP
if (opEvent && eTag[1] === opEvent.id) {
return opEvent;
}
// Check if parent is another reply
return threadEvents.find((e) => e.id === eTag[1]);
}
return undefined;
}
function sortThreadItems(): Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> {
const items: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = [];
for (const event of threadEvents) {
if (event.kind === 9735) {
items.push({ event, type: 'zap' });
} else if (event.kind === 1244) {
items.push({ event, type: 'yak' });
} else if (event.kind === 1) {
items.push({ event, type: 'reply' });
} else if (event.kind === 1111) {
items.push({ event, type: 'comment' });
}
}
// Build thread structure
const eventMap = new Map<string, { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }>();
const replyMap = new Map<string, string[]>();
const rootItems: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = [];
// First pass: build maps
for (const item of items) {
eventMap.set(item.event.id, item);
}
// Second pass: determine parent-child relationships
for (const item of items) {
const eTag = item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id);
const parentId = eTag?.[1];
if (parentId) {
// Check if parent is OP or another reply
if (opEvent && parentId === opEvent.id) {
// Direct reply to OP
rootItems.push(item);
} else if (eventMap.has(parentId)) {
// Reply to another reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
} else {
// Parent not found - treat as root
rootItems.push(item);
}
} else {
// No parent tag - treat as root
rootItems.push(item);
}
}
// Third pass: recursively collect in thread order
const result: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = [];
const processed = new Set<string>();
function addThread(item: { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }) {
if (processed.has(item.event.id)) return;
processed.add(item.event.id);
result.push(item);
const replies = replyMap.get(item.event.id) || [];
const replyItems = replies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at);
for (const reply of replyItems) {
addThread(reply);
}
}
// Sort root items by created_at
rootItems.sort((a, b) => a.event.created_at - b.event.created_at);
for (const root of rootItems) {
addThread(root);
}
return result;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleEscape} />
{#if isOpen}
<div
class="drawer-backdrop"
onclick={handleBackdropClick}
onkeydown={(e) => {
if (e.key === 'Escape') {
onClose();
}
}}
role="button"
tabindex="0"
aria-label="Close drawer"
transition:fade={{ duration: 200 }}
>
<div class="drawer" transition:slide={{ axis: 'x', duration: 300 }}>
<div class="drawer-header">
<h2 class="drawer-title">Thread</h2>
<button class="close-button" onclick={onClose} aria-label="Close drawer">
×
</button>
</div>
<div class="drawer-content">
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p>
{:else if opEvent}
<!-- OP with reactions -->
<div class="op-section">
<FeedPost post={opEvent} />
<div class="reactions-section">
<FeedReactionButtons event={opEvent} />
</div>
</div>
<!-- Threaded replies -->
{#if threadEvents.length > 0}
<div class="replies-section">
<h3 class="replies-title">Replies</h3>
<div class="replies-list">
{#each sortThreadItems() as item (item.event.id)}
{@const parentEvent = getParentEvent(item.event)}
{#if item.type === 'zap'}
<ZapReceiptReply
zapReceipt={item.event}
parentEvent={parentEvent || opEvent}
/>
{:else if item.type === 'yak'}
<!-- Yak back (voice reply) - TODO: create component or use existing -->
<div class="yak-reply">
<p class="text-fog-text-light dark:text-fog-dark-text-light">Voice reply (kind 1244) - TODO: implement component</p>
</div>
{:else if item.type === 'reply'}
<FeedPost
post={item.event}
parentEvent={parentEvent || opEvent}
/>
{:else if item.type === 'comment'}
<Comment
comment={item.event}
parentEvent={parentEvent || opEvent}
/>
{/if}
{/each}
</div>
</div>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No replies yet.</p>
{/if}
{/if}
</div>
</div>
</div>
{/if}
<style>
.drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
justify-content: flex-end;
}
:global(.dark) .drawer-backdrop {
background: rgba(0, 0, 0, 0.7);
}
.drawer {
width: 100%;
max-width: 600px;
height: 100%;
background: var(--fog-post, #ffffff);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .drawer {
background: var(--fog-dark-post, #1f2937);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .drawer-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.drawer-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--fog-text, #111827);
}
:global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb);
}
.close-button {
background: none;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #6b7280);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
color: var(--fog-text, #111827);
}
:global(.dark) .close-button:hover {
color: var(--fog-dark-text, #f9fafb);
}
.drawer-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.op-section {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .op-section {
border-bottom-color: var(--fog-dark-border, #374151);
}
.reactions-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .reactions-section {
border-top-color: var(--fog-dark-border, #374151);
}
.replies-section {
margin-top: 2rem;
}
.replies-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--fog-text, #111827);
}
:global(.dark) .replies-title {
color: var(--fog-dark-text, #f9fafb);
}
.replies-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.yak-reply {
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .yak-reply {
background: var(--fog-dark-highlight, #374151);
}
</style>

16
src/lib/modules/feed/ZapReceiptReply.svelte

@ -51,6 +51,11 @@
return zapReceipt.pubkey; return zapReceipt.pubkey;
} }
function isReply(): boolean {
// Check if this zap receipt is a reply (has e tag pointing to another event)
return zapReceipt.tags.some((t) => t[0] === 'e' && t[1] !== zapReceipt.id);
}
$effect(() => { $effect(() => {
if (contentElement) { if (contentElement) {
checkContentHeight(); checkContentHeight();
@ -82,7 +87,10 @@
<article class="zap-receipt-reply" id="event-{zapReceipt.id}" data-event-id={zapReceipt.id}> <article class="zap-receipt-reply" id="event-{zapReceipt.id}" data-event-id={zapReceipt.id}>
<div class="card-content" class:expanded bind:this={contentElement}> <div class="card-content" class:expanded bind:this={contentElement}>
{#if parentEvent} {#if parentEvent}
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" /> <div class="zap-reply-context">
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">⚡ Zapping</span>
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" />
</div>
{/if} {/if}
<div class="zap-header flex items-center gap-2 mb-2"> <div class="zap-header flex items-center gap-2 mb-2">
@ -90,12 +98,6 @@
<span class="text-lg"></span> <span class="text-lg"></span>
<span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span> <span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
{#if getZappedPubkey()}
{@const zappedPubkey = getZappedPubkey()!}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
to <ProfileBadge pubkey={zappedPubkey} />
</span>
{/if}
</div> </div>
{#if zapReceipt.content} {#if zapReceipt.content}

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

@ -19,6 +19,7 @@ class NostrClient {
private relays: Map<string, Relay> = new Map(); private relays: Map<string, Relay> = new Map();
private subscriptions: Map<string, { relay: Relay; sub: any }> = new Map(); private subscriptions: Map<string, { relay: Relay; sub: any }> = new Map();
private nextSubId = 1; private nextSubId = 1;
private activeFetches: Map<string, Promise<NostrEvent[]>> = new Map(); // Track active fetches to prevent duplicates
/** /**
* Initialize the client * Initialize the client
@ -359,6 +360,16 @@ class NostrClient {
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const { useCache = true, cacheResults = true, onUpdate } = options || {}; const { useCache = true, cacheResults = true, onUpdate } = options || {};
// Create a key for this fetch to prevent duplicate concurrent requests
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 from cache first if enabled
if (useCache) { if (useCache) {
try { try {
@ -366,17 +377,23 @@ class NostrClient {
if (cachedEvents.length > 0) { if (cachedEvents.length > 0) {
// Return cached events immediately // Return cached events immediately
if (onUpdate) { // Don't call onUpdate here - only call it when fresh data arrives
setTimeout(() => onUpdate(cachedEvents), 0); // This prevents duplicate updates that cause feed jumping
}
// Fetch fresh data in background // Fetch fresh data in background (only if cacheResults is true)
// Add a delay to prevent immediate background refresh that might cause rate limiting
if (cacheResults) { if (cacheResults) {
setTimeout(() => { setTimeout(() => {
this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout }).catch((error) => { // 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);
bgPromise.finally(() => {
this.activeFetches.delete(bgFetchKey);
}).catch((error) => {
console.error('Error fetching fresh events from relays:', error); console.error('Error fetching fresh events from relays:', error);
}); });
}, 0); }, 1000); // Delay background refresh by 1 second to reduce concurrent requests
} }
return cachedEvents; return cachedEvents;
@ -387,7 +404,12 @@ class NostrClient {
} }
// Fetch from relays // Fetch from relays
return this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout }); const fetchPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout });
this.activeFetches.set(fetchKey, fetchPromise);
fetchPromise.finally(() => {
this.activeFetches.delete(fetchKey);
});
return fetchPromise;
} }

2
src/lib/types/kind-lookup.ts

@ -49,7 +49,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
30315: { number: 30315, description: 'User Status', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, 30315: { number: 30315, description: 'User Status', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
// Zaps // Zaps
9735: { number: 9735, description: 'Zap Receipt', showInFeed: true, isReplaceable: false, isSecondaryKind: true }, 9735: { number: 9735, description: 'Zap Receipt', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
// Relay lists // Relay lists
10002: { number: 10002, description: 'Relay List Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, 10002: { number: 10002, description: 'Relay List Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false },

Loading…
Cancel
Save