diff --git a/docker-compose.yml b/docker-compose.yml index 1793caa..815c22b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: . args: - VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.wine,wss://nostr.sovbit.host,wss://orly-relay.imwald.eu" + VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.sovbit.host,wss://orly-relay.imwald.eu" VITE_ZAP_THRESHOLD: "1" VITE_THREAD_TIMEOUT_DAYS: "30" VITE_PWA_ENABLED: "true" diff --git a/public/healthz.json b/public/healthz.json index c45da43..c646a13 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-03T09:44:26.608Z", + "buildTime": "2026-02-03T11:48:59.137Z", "gitCommit": "unknown", - "timestamp": 1770111866608 + "timestamp": 1770119339137 } \ No newline at end of file diff --git a/src/app.css b/src/app.css index ecb619f..71209d9 100644 --- a/src/app.css +++ b/src/app.css @@ -92,25 +92,27 @@ body { background-color: #1e293b; } -/* Anon aesthetic: Grayscale with blue tinge for profile pics and emojis */ +/* Anon aesthetic: Pure gray to slightly bluish tints for profile pics */ /* Profile pictures - all instances */ -.profile-picture, +img.profile-picture, .profile-badge img, +.profile-picture, img[alt*="profile" i], img[alt*="avatar" i], img[src*="avatar" i], img[src*="profile" i] { - filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); + filter: grayscale(100%) sepia(10%) hue-rotate(200deg) saturate(30%) !important; transition: filter 0.3s ease; } -.dark .profile-picture, +.dark img.profile-picture, .dark .profile-badge img, +.dark .profile-picture, .dark img[alt*="profile" i], .dark img[alt*="avatar" i], .dark img[src*="avatar" i], .dark img[src*="profile" i] { - filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); + filter: grayscale(100%) sepia(12%) hue-rotate(200deg) saturate(35%) !important; } /* Emoji images - grayscale like profile pics */ diff --git a/src/lib/components/content/QuotedContext.svelte b/src/lib/components/content/QuotedContext.svelte index 53dd01f..d362777 100644 --- a/src/lib/components/content/QuotedContext.svelte +++ b/src/lib/components/content/QuotedContext.svelte @@ -2,6 +2,7 @@ import type { NostrEvent } from '../../types/nostr.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; + import { stripMarkdown } from '../../services/text-utils.js'; interface Props { quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId @@ -63,8 +64,8 @@ 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(); + // Create preview from quoted event (first 100 chars, plaintext with markdown stripped) + const plaintext = stripMarkdown(quotedEvent.content); return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); } diff --git a/src/lib/components/content/ReplyContext.svelte b/src/lib/components/content/ReplyContext.svelte index 5aa4596..a78f799 100644 --- a/src/lib/components/content/ReplyContext.svelte +++ b/src/lib/components/content/ReplyContext.svelte @@ -2,6 +2,7 @@ import type { NostrEvent } from '../../types/nostr.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; + import { stripMarkdown } from '../../services/text-utils.js'; interface Props { parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId @@ -63,8 +64,8 @@ if (!parentEvent) { return loadingParent ? 'Loading...' : 'Parent event not found'; } - // Create preview from parent (first 100 chars, plaintext) - const plaintext = parentEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim(); + // Create preview from parent (first 100 chars, plaintext with markdown stripped) + const plaintext = stripMarkdown(parentEvent.content); return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); } diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte index 5f593dc..e7cc457 100644 --- a/src/lib/components/layout/ProfileBadge.svelte +++ b/src/lib/components/layout/ProfileBadge.svelte @@ -1,6 +1,7 @@ @@ -85,13 +116,13 @@ {:else}
{avatarInitials}
{/if} - {profile?.name || pubkey.slice(0, 16)}... + {profile?.name || shortenedNpub} {#if activityStatus && activityMessage} e.kind === 1); const updatedReplaceable = updated.filter((e: NostrEvent) => @@ -128,7 +129,7 @@ // 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 + // 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)); @@ -142,15 +143,17 @@ 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; + // 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; } - } else { - // If no lastPostId, count all new posts - newPostsCount += trulyNewPosts.length; } } } @@ -212,36 +215,31 @@ // 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); lastPostId = regularPosts.length > 0 ? regularPosts[0].id : null; - // Invalidate cache - cachedFeedItems = null; } else { // For infinite scroll (loading more), add new posts directly to feed // This is a user-initiated action, so update immediately - // Don't re-sort existing posts - just append new ones to prevent jumping - const existingIds = new Set(posts.map(p => p.id)); - const allNewEvents = [...regularPosts, ...otherFeedEvents]; - const newPosts = allNewEvents.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]; - // Invalidate cache - cachedFeedItems = null; - } - - const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id)); - const newReplaceable = replaceable.filter(e => !existingReplaceableIds.has(e.id)); - 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; + // 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 allNewEvents = [...regularPosts, ...otherFeedEvents]; + const newPosts = allNewEvents.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]; + } + + const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id)); + const newReplaceable = replaceable.filter(e => !existingReplaceableIds.has(e.id)); + 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)]; + } } } @@ -286,33 +284,61 @@ }); } - // 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) - const uniquePubkeys = new Set(); - for (const event of [...posts, ...replaceableEvents]) { - uniquePubkeys.add(event.pubkey); - } - - if (uniquePubkeys.size > 0) { - const profileFilter = [{ - kinds: [0], - authors: Array.from(uniquePubkeys).slice(0, 100) // Limit to avoid huge requests - }]; + // 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(); + for (const event of [...posts, ...replaceableEvents]) { + uniquePubkeys.add(event.pubkey); + } - // 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( - profileFilter, - relays, - { - useCache: true, - cacheResults: true, - timeout: 10000 - } - ).catch(err => { - console.error('Error fetching profiles:', err); - }); - } + 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); + }); + } // Fetch missing parent events (batch, one request per relay) const allEventIds = new Set(posts.map(e => e.id)); @@ -428,24 +454,21 @@ return result; } - - function handleShowNewPosts() { + // Don't update feed while drawer is open - freeze in place + if (drawerOpen) return; + // 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 @@ -507,10 +530,6 @@ 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 (but allow the wrapper itself) if (e) { @@ -537,17 +556,21 @@ function closeThreadDrawer() { drawerOpen = false; selectedEvent = null; - } - - 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; + // Events that arrived while drawer was open are already in pendingNewPosts/pendingNewReplaceable + // 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; type: 'post' | 'replaceable'; created_at: number }> = []; // Add filtered posts @@ -571,15 +594,9 @@ }); } - // Sort by created_at, newest first (only when cache is invalid) - const sorted = items.sort((a, b) => b.created_at - a.created_at); - - // Cache the result - cachedFeedItems = sorted; - cachedFeedItemsKey = currentKey; - - return sorted; - } + // Sort by created_at, newest first + return items.sort((a, b) => b.created_at - a.created_at); + });
@@ -623,7 +640,7 @@
{/if}
- {#each getAllFeedItems() as item (item.id)} + {#each allFeedItems as item (item.id)} {#if item.type === 'post'} {@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} @@ -673,7 +690,7 @@ {#if loadingMore}

Loading more...

{/if} - {#if !hasMore && getAllFeedItems().length > 0} + {#if !hasMore && allFeedItems.length > 0}

No more posts

{/if} {#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0} diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index 55c0df1..704e0e7 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -9,6 +9,7 @@ import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; import { getKindInfo } from '../../types/kind-lookup.js'; + import { stripMarkdown } from '../../services/text-utils.js'; interface Props { post: NostrEvent; @@ -17,9 +18,10 @@ onReply?: (post: NostrEvent) => void; onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded + previewMode?: boolean; // If true, show only title and first 150 chars of content } - let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded }: Props = $props(); + let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, previewMode = false }: Props = $props(); let loadedParentEvent = $state(null); let loadingParent = $state(false); @@ -150,69 +152,106 @@ expanded = !expanded; } - + function getTitle(): string { + const titleTag = post.tags.find((t) => t[0] === 'title'); + return titleTag?.[1] || 'Untitled'; + } -
-
- {#if isReply()} - - {/if} + function getPreviewContent(): string { + // First 150 chars, plaintext (no markdown/images) + const plaintext = stripMarkdown(post.content); + return plaintext.slice(0, 150) + (plaintext.length > 150 ? '...' : ''); + } - {#if hasQuotedEvent()} - - {/if} + -
- - {getRelativeTime()} - {#if getClientName()} - via {getClientName()} - {/if} +
+ {#if previewMode} + + + {:else} + +
{#if isReply()} - ↳ Reply + {/if} -
-
- -
- -
- - {#if onReply} - + {#if hasQuotedEvent()} + {/if} + +
+ + {getRelativeTime()} + {#if getClientName()} + via {getClientName()} + {/if} + {#if isReply()} + ↳ Reply + {/if} +
+ +
+ +
+ +
+ + {#if onReply} + + {/if} +
+
+ + {#if needsExpansion} + + {/if} + +
+ {getKindInfo(post.kind).number} + {getKindInfo(post.kind).description}
-
- - {#if needsExpansion} - {/if} - -
- {getKindInfo(post.kind).number} - {getKindInfo(post.kind).description} -
diff --git a/src/lib/modules/threads/ThreadCard.svelte b/src/lib/modules/threads/ThreadCard.svelte index b44da99..55972f3 100644 --- a/src/lib/modules/threads/ThreadCard.svelte +++ b/src/lib/modules/threads/ThreadCard.svelte @@ -5,6 +5,7 @@ import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; import { getKindInfo } from '../../types/kind-lookup.js'; + import { stripMarkdown } from '../../services/text-utils.js'; interface Props { thread: NostrEvent; @@ -162,7 +163,7 @@ function getPreview(): string { // First 250 chars, plaintext (no markdown/images) - const plaintext = thread.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' '); + const plaintext = stripMarkdown(thread.content); return plaintext.slice(0, 250) + (plaintext.length > 250 ? '...' : ''); } diff --git a/src/lib/modules/threads/ThreadList.svelte b/src/lib/modules/threads/ThreadList.svelte index 7aed3f3..2e3e114 100644 --- a/src/lib/modules/threads/ThreadList.svelte +++ b/src/lib/modules/threads/ThreadList.svelte @@ -20,6 +20,25 @@ loadThreads(); }); + // Re-sort threads when sortBy changes (but not when threads changes) + let lastSortBy = $state<'newest' | 'active' | 'upvoted' | null>(null); + $effect(() => { + // Only re-sort if sortBy actually changed + if (sortBy !== lastSortBy && threads.length > 0 && !loading) { + lastSortBy = sortBy; + + if (sortBy === 'newest') { + threads = sortThreadsSync(threads); + } else { + // For async sorts, trigger the sort without blocking + // Pass sortBy explicitly to ensure we use the current value + sortThreads(threads, sortBy).then(sorted => { + threads = sorted; + }); + } + } + }); + async function loadThreads() { loading = true; try { @@ -39,17 +58,23 @@ cacheResults: true, onUpdate: async (updatedEvents) => { // Update threads when fresh data arrives from relays - threads = await sortThreads(updatedEvents); + // Pass sortBy explicitly to ensure we use the current value + threads = await sortThreads(updatedEvents, sortBy); } } ); - // Set initial cached data immediately - if (sortBy === 'newest') { + // Set initial cached data immediately using current sortBy value + // Capture sortBy at this point to ensure we use the correct value + const currentSort = sortBy; + if (currentSort === 'newest') { threads = sortThreadsSync(events); } else { - threads = await sortThreads(events); + // Pass sortBy explicitly to ensure we use the current value + threads = await sortThreads(events, currentSort); } + // Update lastSortBy to match current sort so effect doesn't re-trigger + lastSortBy = currentSort; } catch (error) { console.error('Error loading threads:', error); threads = []; // Set empty array on error to prevent undefined issues @@ -63,69 +88,114 @@ return [...events].sort((a, b) => b.created_at - a.created_at); } - async function sortThreads(events: NostrEvent[]): Promise { - switch (sortBy) { + async function sortThreads(events: NostrEvent[], sortType: 'newest' | 'active' | 'upvoted' = sortBy): Promise { + switch (sortType) { case 'newest': return sortThreadsSync(events); case 'active': // Sort by most recent activity (comments, reactions, or zaps) // Thread bumping: active threads rise to top - const activeSorted = await Promise.all( - events.map(async (event) => { - const commentRelays = relayManager.getCommentReadRelays(); - const reactionRelays = relayManager.getThreadReadRelays(); - const zapRelays = relayManager.getZapReceiptReadRelays(); - - // Get most recent comment - const comments = await nostrClient.fetchEvents( - [{ kinds: [1111], '#E': [event.id], '#K': ['11'], limit: 1 }], - commentRelays, - { useCache: true } - ); - const lastCommentTime = comments.length > 0 - ? comments.sort((a, b) => b.created_at - a.created_at)[0].created_at - : 0; - - // Get most recent reaction - const reactions = await nostrClient.fetchEvents( - [{ kinds: [7], '#e': [event.id], limit: 1 }], - reactionRelays, - { useCache: true } - ); - const lastReactionTime = reactions.length > 0 - ? reactions.sort((a, b) => b.created_at - a.created_at)[0].created_at - : 0; - - // Last activity is the most recent of all activities - const lastActivity = Math.max( - event.created_at, - lastCommentTime, - lastReactionTime - ); - - return { event, lastActivity }; - }) + // 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(); + + // Batch fetch all comments for all threads + const allComments = await nostrClient.fetchEvents( + [{ kinds: [1111], '#E': threadIds, '#K': ['11'] }], + commentRelays, + { useCache: true } ); + + // Batch fetch all reactions for all threads + const allReactions = await nostrClient.fetchEvents( + [{ kinds: [7], '#e': threadIds }], + reactionRelays, + { useCache: true } + ); + + // Group comments and reactions by thread ID + const commentsByThread = new Map(); + const reactionsByThread = new Map(); + + for (const comment of allComments) { + const threadId = comment.tags.find(t => t[0] === 'E' || t[0] === 'e')?.[1]; + if (threadId) { + if (!commentsByThread.has(threadId)) { + commentsByThread.set(threadId, []); + } + commentsByThread.get(threadId)!.push(comment); + } + } + + for (const reaction of allReactions) { + const threadId = reaction.tags.find(t => t[0] === 'e')?.[1]; + if (threadId) { + if (!reactionsByThread.has(threadId)) { + reactionsByThread.set(threadId, []); + } + reactionsByThread.get(threadId)!.push(reaction); + } + } + + // Calculate last activity for each thread + const activeSorted = events.map((event) => { + const comments = commentsByThread.get(event.id) || []; + const reactions = reactionsByThread.get(event.id) || []; + + const lastCommentTime = comments.length > 0 + ? Math.max(...comments.map(c => c.created_at)) + : 0; + + const lastReactionTime = reactions.length > 0 + ? Math.max(...reactions.map(r => r.created_at)) + : 0; + + const lastActivity = Math.max( + event.created_at, + lastCommentTime, + lastReactionTime + ); + + return { event, lastActivity }; + }); + return activeSorted .sort((a, b) => b.lastActivity - a.lastActivity) .map(({ event }) => event); case 'upvoted': // Sort by upvote count - const upvotedSorted = await Promise.all( - events.map(async (event) => { - const config = nostrClient.getConfig(); - const reactionRelays = relayManager.getThreadReadRelays(); - const reactions = await nostrClient.fetchEvents( - [{ kinds: [7], '#e': [event.id] }], - reactionRelays, - { useCache: true } - ); - const upvoteCount = reactions.filter( - (r) => r.content.trim() === '+' || r.content.trim() === '⬆️' || r.content.trim() === '↑' - ).length; - return { event, upvotes: upvoteCount }; - }) + // Batch fetch all reactions at once to avoid concurrent request issues + const allThreadIds = events.map(e => e.id); + const reactionRelaysForUpvotes = relayManager.getThreadReadRelays(); + + const allReactionsForUpvotes = await nostrClient.fetchEvents( + [{ kinds: [7], '#e': allThreadIds }], + reactionRelaysForUpvotes, + { useCache: true } ); + + // Group reactions by thread ID + const reactionsByThreadForUpvotes = new Map(); + for (const reaction of allReactionsForUpvotes) { + const threadId = reaction.tags.find(t => t[0] === 'e')?.[1]; + if (threadId) { + if (!reactionsByThreadForUpvotes.has(threadId)) { + reactionsByThreadForUpvotes.set(threadId, []); + } + reactionsByThreadForUpvotes.get(threadId)!.push(reaction); + } + } + + // 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 }; + }); + return upvotedSorted .sort((a, b) => b.upvotes - a.upvotes) .map(({ event }) => event); @@ -245,7 +315,7 @@ /> Show older threads - @@ -299,7 +369,7 @@ } }} > - +
{/each} @@ -319,7 +389,7 @@ } }} > - + {/each} {/each} @@ -342,7 +412,7 @@ } }} > - + {/each} {#if getFilteredThreads().length === 0} diff --git a/src/lib/services/auth/activity-tracker.ts b/src/lib/services/auth/activity-tracker.ts index 81a3c09..342a434 100644 --- a/src/lib/services/auth/activity-tracker.ts +++ b/src/lib/services/auth/activity-tracker.ts @@ -7,9 +7,11 @@ import type { NostrEvent } from '../../types/nostr.js'; /** * Get last activity timestamp for a pubkey + * Only checks cache - does not make relay requests + * Parent components should batch fetch activity data upfront */ export async function getLastActivity(pubkey: string): Promise { - // Query for recent events from this pubkey + // Query for recent events from this pubkey (cache only) const filters = [ { authors: [pubkey], kinds: [0, 1, 7, 11, 1111], limit: 1 } ]; diff --git a/src/lib/services/cache/event-cache.ts b/src/lib/services/cache/event-cache.ts index cd1dd62..1b41aa4 100644 --- a/src/lib/services/cache/event-cache.ts +++ b/src/lib/services/cache/event-cache.ts @@ -13,78 +13,103 @@ export interface CachedEvent extends NostrEvent { * Store an event in cache */ export async function cacheEvent(event: NostrEvent): Promise { - const db = await getDB(); - const cached: CachedEvent = { - ...event, - cached_at: Date.now() - }; - await db.put('events', cached); + try { + 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 + } } /** * Store multiple events in cache */ export async function cacheEvents(events: NostrEvent[]): Promise { - 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); + 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; + } catch (error) { + console.debug('Error caching events:', error); + // Don't throw - caching failures shouldn't break the app } - await tx.done; } /** * Get event by ID from cache */ export async function getEvent(id: string): Promise { - const db = await getDB(); - return db.get('events', id); + try { + const db = await getDB(); + return await db.get('events', id); + } catch (error) { + console.debug('Error getting event from cache:', error); + return undefined; + } } /** * Get events by kind */ export async function getEventsByKind(kind: number, limit?: number): Promise { - 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++; - } + 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++; + } - 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 []; + } } /** * Get events by pubkey */ export async function getEventsByPubkey(pubkey: string, limit?: number): Promise { - 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++; - } + 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++; + } - await tx.done; + await tx.done; - return events.sort((a, b) => b.created_at - a.created_at); + return events.sort((a, b) => b.created_at - a.created_at); + } catch (error) { + console.debug('Error getting events by pubkey from cache:', error); + return []; + } } /** diff --git a/src/lib/services/cache/indexeddb-store.ts b/src/lib/services/cache/indexeddb-store.ts index bd3283b..2be6ef8 100644 --- a/src/lib/services/cache/indexeddb-store.ts +++ b/src/lib/services/cache/indexeddb-store.ts @@ -5,7 +5,7 @@ import { openDB, type IDBPDatabase } from 'idb'; const DB_NAME = 'aitherboard'; -const DB_VERSION = 1; +const DB_VERSION = 2; // Incremented to force upgrade when stores are missing export interface DatabaseSchema { events: { @@ -35,34 +35,98 @@ let dbInstance: IDBPDatabase | null = null; export async function getDB(): Promise> { if (dbInstance) return dbInstance; - dbInstance = await openDB(DB_NAME, DB_VERSION, { - upgrade(db) { - // Events store - if (!db.objectStoreNames.contains('events')) { - const eventStore = db.createObjectStore('events', { keyPath: 'id' }); - eventStore.createIndex('kind', 'kind', { unique: false }); - eventStore.createIndex('pubkey', 'pubkey', { unique: false }); - eventStore.createIndex('created_at', 'created_at', { unique: false }); - } + try { + dbInstance = await openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + // Events store + if (!db.objectStoreNames.contains('events')) { + const eventStore = db.createObjectStore('events', { keyPath: 'id' }); + eventStore.createIndex('kind', 'kind', { unique: false }); + eventStore.createIndex('pubkey', 'pubkey', { unique: false }); + eventStore.createIndex('created_at', 'created_at', { unique: false }); + } - // 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'); + }, + blocking() { + // Close connection if another tab wants to upgrade + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } } + }); + + // Verify all stores exist after opening - if not, database is corrupted + if (!dbInstance.objectStoreNames.contains('events') || + !dbInstance.objectStoreNames.contains('profiles') || + !dbInstance.objectStoreNames.contains('keys') || + !dbInstance.objectStoreNames.contains('search')) { + // Database is corrupted - close and delete it, then recreate + console.warn('Database missing required stores, recreating...'); + dbInstance.close(); + dbInstance = null; + + // Delete the corrupted database + const deleteReq = indexedDB.deleteDatabase(DB_NAME); + await new Promise((resolve, reject) => { + deleteReq.onsuccess = () => resolve(); + deleteReq.onerror = () => reject(deleteReq.error); + deleteReq.onblocked = () => { + console.warn('Database deletion blocked - another tab may have it open'); + resolve(); // Continue anyway + }; + }); + + // Wait a bit for deletion to complete + await new Promise(resolve => setTimeout(resolve, 100)); + + // Recreate database + dbInstance = await openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + 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 }); + db.createObjectStore('profiles', { keyPath: 'pubkey' }); + db.createObjectStore('keys', { keyPath: 'id' }); + db.createObjectStore('search', { keyPath: 'id' }); + }, + blocked() { + console.warn('IndexedDB is blocked - another tab may have it open'); + }, + blocking() { + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } + } + }); } - }); - return dbInstance; + return dbInstance; + } catch (error) { + console.error('Failed to open IndexedDB:', error); + // Reset instance so we can retry + dbInstance = null; + throw error; + } } /** @@ -74,3 +138,13 @@ export async function closeDB(): Promise { dbInstance = null; } } + +/** + * Reset database instance (useful for recovery from errors) + */ +export function resetDB(): void { + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } +} diff --git a/src/lib/services/cache/profile-cache.ts b/src/lib/services/cache/profile-cache.ts index 228d689..ef7c1f0 100644 --- a/src/lib/services/cache/profile-cache.ts +++ b/src/lib/services/cache/profile-cache.ts @@ -16,38 +16,53 @@ export interface CachedProfile { */ export async function cacheProfile(event: NostrEvent): Promise { if (event.kind !== 0) throw new Error('Not a profile event'); - const db = await getDB(); - const cached: CachedProfile = { - pubkey: event.pubkey, - event, - cached_at: Date.now() - }; - await db.put('profiles', cached); + try { + 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 + } } /** * Get profile by pubkey from cache */ export async function getProfile(pubkey: string): Promise { - const db = await getDB(); - return db.get('profiles', pubkey); + try { + const db = await getDB(); + return await db.get('profiles', pubkey); + } catch (error) { + console.debug('Error getting profile from cache:', error); + return undefined; + } } /** * Get multiple profiles */ export async function getProfiles(pubkeys: string[]): Promise> { - const db = await getDB(); - const profiles = new Map(); - const tx = db.transaction('profiles', 'readonly'); + try { + const db = await getDB(); + const profiles = new Map(); + const tx = db.transaction('profiles', 'readonly'); - for (const pubkey of pubkeys) { - const profile = await tx.store.get(pubkey); - if (profile) { - profiles.set(pubkey, profile); + for (const pubkey of pubkeys) { + const profile = await tx.store.get(pubkey); + if (profile) { + profiles.set(pubkey, profile); + } } - } - await tx.done; - return profiles; + await tx.done; + return profiles; + } catch (error) { + console.debug('Error getting profiles from cache:', error); + return new Map(); + } } diff --git a/src/lib/services/nostr/config.ts b/src/lib/services/nostr/config.ts index e7cbc74..1170098 100644 --- a/src/lib/services/nostr/config.ts +++ b/src/lib/services/nostr/config.ts @@ -7,7 +7,6 @@ const DEFAULT_RELAYS = [ 'wss://theforest.nostr1.com', 'wss://nostr21.com', 'wss://nostr.land', - 'wss://nostr.wine', 'wss://nostr.sovbit.host', 'wss://orly-relay.imwald.eu' ]; @@ -24,6 +23,8 @@ const THREAD_PUBLISH_RELAYS = [ const RELAY_TIMEOUT = 10000; +const ZAP_THRESHOLD = 1; + export interface NostrConfig { defaultRelays: string[]; profileRelays: string[]; diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 51413d9..64cd49c 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -116,10 +116,31 @@ class NostrClient { 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 + + const amountTag = event.tags.find((t) => t[0] === 'amount'); + if (!amountTag || !amountTag[1]) return true; // Filter if no amount tag + + 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; + } + /** * 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); @@ -130,34 +151,45 @@ class NostrClient { * Get events from cache that match filters */ private async getCachedEvents(filters: Filter[]): Promise { - const results: NostrEvent[] = []; - const seen = new Set(); - - // Query IndexedDB for each filter - for (const filter of filters) { - if (filter.kinds && filter.kinds.length === 1) { - const events = await getEventsByKind(filter.kinds[0], filter.limit || 50); - for (const event of events) { - if (seen.has(event.id)) continue; - if (matchFilter(filter, event)) { - results.push(event); - seen.add(event.id); + try { + const results: NostrEvent[] = []; + const seen = new Set(); + + // Query IndexedDB for each filter + for (const filter of filters) { + try { + if (filter.kinds && filter.kinds.length === 1) { + const events = await getEventsByKind(filter.kinds[0], filter.limit || 50); + for (const event of events) { + if (seen.has(event.id)) continue; + if (matchFilter(filter, event)) { + results.push(event); + seen.add(event.id); + } + } } - } - } - if (filter.authors && filter.authors.length === 1) { - const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50); - for (const event of events) { - if (seen.has(event.id)) continue; - if (matchFilter(filter, event)) { - results.push(event); - seen.add(event.id); + if (filter.authors && filter.authors.length === 1) { + const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50); + for (const event of events) { + if (seen.has(event.id)) continue; + if (matchFilter(filter, event)) { + results.push(event); + seen.add(event.id); + } + } } + } catch (error) { + // If cache access fails for a specific filter, log and continue + console.debug('Error accessing cache for filter:', error); } } - } - return filterEvents(results); + return filterEvents(results); + } catch (error) { + // If cache is completely unavailable, return empty array + console.debug('Cache unavailable, returning empty results:', error); + return []; + } } /** @@ -291,6 +323,8 @@ class NostrClient { 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 @@ -469,6 +503,8 @@ class NostrClient { 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); }, @@ -506,19 +542,22 @@ class NostrClient { const eventArray = Array.from(events.values()); const filtered = filterEvents(eventArray); - // Cache results in background - if (options.cacheResults && filtered.length > 0) { - cacheEvents(filtered).catch((error) => { + // 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); }); } - // Call onUpdate callback + // Call onUpdate callback (with zap-filtered results) if (options.onUpdate) { - options.onUpdate(filtered); + options.onUpdate(zapFiltered); } - return filtered; + return zapFiltered; } diff --git a/src/lib/services/text-utils.ts b/src/lib/services/text-utils.ts new file mode 100644 index 0000000..7fcf632 --- /dev/null +++ b/src/lib/services/text-utils.ts @@ -0,0 +1,39 @@ +/** + * Text utility functions + */ + +/** + * Strip markdown formatting from text, returning plain text + */ +export function stripMarkdown(text: string): string { + // Remove code blocks (```code```) + text = text.replace(/```[\s\S]*?```/g, ''); + // Remove inline code (`code`) + text = text.replace(/`[^`]*`/g, ''); + // Remove images (![alt](url)) + text = text.replace(/!\[([^\]]*)\]\([^\)]*\)/g, ''); + // Remove links ([text](url)) - keep the link text + text = text.replace(/\[([^\]]*)\]\([^\)]*\)/g, '$1'); + // Remove headers (# ## ###) + text = text.replace(/^#{1,6}\s+/gm, ''); + // Remove bold (**text** or __text__) + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + text = text.replace(/__([^_]+)__/g, '$1'); + // Remove italic (*text* or _text_) + text = text.replace(/\*([^*]+)\*/g, '$1'); + text = text.replace(/_([^_]+)_/g, '$1'); + // Remove strikethrough (~~text~~) + text = text.replace(/~~([^~]+)~~/g, '$1'); + // Remove blockquotes (> text) + text = text.replace(/^>\s+/gm, ''); + // Remove list markers (- * + or 1. 2. etc) + text = text.replace(/^[\s]*[-*+]\s+/gm, ''); + text = text.replace(/^\d+\.\s+/gm, ''); + // Remove horizontal rules (--- or ***) + text = text.replace(/^[-*]{3,}$/gm, ''); + // Replace newlines with spaces + text = text.replace(/\n/g, ' '); + // Remove extra whitespace + text = text.replace(/\s+/g, ' ').trim(); + return text; +}