From ce93fac88948e1c2ee3628a96e66809bb1daa075 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 4 Feb 2026 17:31:33 +0100 Subject: [PATCH] bug-fixes --- public/healthz.json | 4 +- src/lib/components/EventMenu.svelte | 46 +- .../components/profile/BookmarksPanel.svelte | 219 +++++++ src/lib/components/profile/ProfileMenu.svelte | 458 ++++++++++++++ .../components/write/CreateEventForm.svelte | 46 +- src/lib/modules/feed/FeedPage.svelte | 301 +++++++-- src/lib/modules/feed/FeedPost.svelte | 25 +- src/lib/modules/feed/HighlightCard.svelte | 124 +++- .../modules/profiles/PaymentAddresses.svelte | 15 +- src/lib/modules/profiles/ProfilePage.svelte | 111 +++- src/lib/services/cache/deletion-tracker.ts | 13 +- src/lib/services/cache/event-cache.ts | 17 +- src/lib/services/user-actions.ts | 581 +++++++++++++++--- src/lib/types/kind-lookup.ts | 8 +- 14 files changed, 1773 insertions(+), 195 deletions(-) create mode 100644 src/lib/components/profile/BookmarksPanel.svelte create mode 100644 src/lib/components/profile/ProfileMenu.svelte diff --git a/public/healthz.json b/public/healthz.json index fe4e0d3..b1f6088 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-04T14:48:13.641Z", + "buildTime": "2026-02-04T16:28:03.763Z", "gitCommit": "unknown", - "timestamp": 1770216493641 + "timestamp": 1770222483764 } \ No newline at end of file diff --git a/src/lib/components/EventMenu.svelte b/src/lib/components/EventMenu.svelte index 62caa7b..f94692d 100644 --- a/src/lib/components/EventMenu.svelte +++ b/src/lib/components/EventMenu.svelte @@ -55,12 +55,21 @@ let pinnedState = $state(false); let bookmarkedState = $state(false); let highlightedState = $state(false); + let stateUpdateTrigger = $state(0); // Trigger to force state updates - // Update state when event changes + // Update state when event changes or when trigger changes $effect(() => { - pinnedState = isPinned(event.id); - bookmarkedState = isBookmarked(event.id); highlightedState = isHighlighted(event.id); + // Access trigger to make effect reactive to it + void stateUpdateTrigger; + + // Update pin and bookmark state asynchronously + isPinned(event.id).then(pinned => { + pinnedState = pinned; + }); + isBookmarked(event.id).then(bookmarked => { + bookmarkedState = bookmarked; + }); }); function toggleMenu() { @@ -231,12 +240,17 @@ } async function pinNote() { - pinnedState = await togglePin(event.id); + await togglePin(event.id); + // Force state update + stateUpdateTrigger++; closeMenu(); } async function bookmarkNote() { - bookmarkedState = await toggleBookmark(event.id); + await toggleBookmark(event.id); + // Force state update by re-checking bookmark status + const newBookmarked = await isBookmarked(event.id); + bookmarkedState = newBookmarked; closeMenu(); } @@ -244,21 +258,25 @@ // Extract content and e/a tags for highlight const content = event.content || ''; - // Find e-tag or a-tag (prefer a-tag if available) - let referenceTag: string[] | null = null; - const aTag = event.tags.find(tag => tag[0] === 'a'); - const eTag = event.tags.find(tag => tag[0] === 'e'); + // Collect all relevant tags: e-tag with the event's ID, a-tag (if available), and p-tag with the event's pubkey + const tagsToInclude: string[][] = []; + // Always add e-tag with the event ID of the event being highlighted + tagsToInclude.push(['e', event.id]); + + // Also check for a-tag in the original event (for parameterized replaceable events) + const aTag = event.tags.find(tag => tag[0] === 'a'); if (aTag) { - referenceTag = aTag; - } else if (eTag) { - referenceTag = eTag; + tagsToInclude.push(aTag); } + // Add p-tag with the pubkey of the event being highlighted + tagsToInclude.push(['p', event.pubkey]); + // Store highlight data in sessionStorage const highlightData = { content, - tags: referenceTag ? [referenceTag] : [] + tags: tagsToInclude }; sessionStorage.setItem('aitherboard_highlightData', JSON.stringify(highlightData)); @@ -446,6 +464,7 @@ justify-content: center; border-radius: 4px; transition: background-color 0.2s, color 0.2s; + filter: grayscale(100%); } .menu-button:hover { @@ -466,6 +485,7 @@ user-select: none; transform: rotate(90deg); display: inline-block; + filter: grayscale(100%); } .menu-dropdown { diff --git a/src/lib/components/profile/BookmarksPanel.svelte b/src/lib/components/profile/BookmarksPanel.svelte new file mode 100644 index 0000000..7cf76d8 --- /dev/null +++ b/src/lib/components/profile/BookmarksPanel.svelte @@ -0,0 +1,219 @@ + + +{#if isOpen} +
{ + // Only close if clicking directly on the overlay, not on the panel + if (e.target === e.currentTarget) { + onClose(); + } + }} + onkeydown={(e) => { + if (e.key === 'Escape') { + onClose(); + } + }} + role="dialog" + aria-modal="true" + aria-label="Bookmarks panel" + tabindex="-1" + > +
+
+

Bookmarks

+ +
+ +
+ {#if loading} +

Loading bookmarks...

+ {:else if bookmarkedEvents.length === 0} +

No bookmarks yet.

+ {:else} +
+ {#each bookmarkedEvents as event (event.id)} + + {/each} +
+ {/if} +
+
+
+{/if} + + + + diff --git a/src/lib/components/profile/ProfileMenu.svelte b/src/lib/components/profile/ProfileMenu.svelte new file mode 100644 index 0000000..9f47e21 --- /dev/null +++ b/src/lib/components/profile/ProfileMenu.svelte @@ -0,0 +1,458 @@ + + +
+ + + {#if menuOpen} + + {/if} +
+ + diff --git a/src/lib/components/write/CreateEventForm.svelte b/src/lib/components/write/CreateEventForm.svelte index 5c4e318..59dc6d7 100644 --- a/src/lib/components/write/CreateEventForm.svelte +++ b/src/lib/components/write/CreateEventForm.svelte @@ -40,7 +40,7 @@ let { initialKind = null, initialContent: propInitialContent = null, initialTags: propInitialTags = null }: Props = $props(); const STORAGE_KEY = 'aitherboard_writeForm_draft'; - + let selectedKind = $state(1); let customKindId = $state(''); let content = $state(''); @@ -119,13 +119,21 @@ } }); - // Sync content and tags when initial props change (only if form is empty) + // Track if we've already applied initial props to prevent re-applying after clear + let initialPropsApplied = $state(false); + let formCleared = $state(false); // Track if form was explicitly cleared + + // Sync content and tags when initial props change (only if form is empty and not yet applied) $effect(() => { + if (initialPropsApplied || formCleared) return; // Don't re-apply after they've been used or after clear + if (propInitialContent !== null && propInitialContent !== undefined && content === '') { content = propInitialContent; + initialPropsApplied = true; } if (propInitialTags !== null && propInitialTags !== undefined && propInitialTags.length > 0 && tags.length === 0) { tags = [...propInitialTags]; + initialPropsApplied = true; } }); @@ -756,13 +764,33 @@ function clearForm() { if (confirm('Are you sure you want to clear the form? This will delete all unsaved content.')) { - content = ''; - tags = []; - uploadedFiles = []; - customKindId = ''; - // Clear draft from localStorage - if (typeof window !== 'undefined') { - localStorage.removeItem(STORAGE_KEY); + try { + // Mark form as cleared to prevent initial props from re-applying + formCleared = true; + + // Clear state synchronously + content = ''; + tags = []; + uploadedFiles = []; + customKindId = ''; + selectedKind = 1; // Reset to default kind + + // Reset the initial props applied flag + initialPropsApplied = false; + + // Clear draft from localStorage after clearing state + // This prevents the save effect from running with old data + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } + + // Reset formCleared flag after a brief delay to allow effects to settle + setTimeout(() => { + formCleared = false; + }, 100); + } catch (error) { + console.error('Error clearing form:', error); + alert('Failed to clear form. Please try again.'); } } } diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index 67aca3d..06547b8 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -7,7 +7,7 @@ import ThreadDrawer from './ThreadDrawer.svelte'; import type { NostrEvent } from '../../types/nostr.js'; import { onMount, tick } from 'svelte'; - import { KIND } from '../../types/kind-lookup.js'; + import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js'; interface Props { singleRelay?: string; // If provided, use only this relay and disable cache @@ -19,6 +19,8 @@ let allPosts = $state([]); // Store all posts before filtering let highlights = $state([]); // Store highlight events (kind 9802) let allHighlights = $state([]); // Store all highlights before filtering + let otherFeedEvents = $state([]); // Store other feed kinds (not kind 1 or 9802) + let allOtherFeedEvents = $state([]); // Store all other feed events before filtering let loading = $state(true); let loadingMore = $state(false); let hasMore = $state(true); @@ -136,9 +138,11 @@ selectedListId = listId; if (!listId) { - // No filter selected - show all posts + // No filter selected - show all posts, highlights, and other feed events listFilterIds = new Set(); posts = [...allPosts]; + highlights = [...allHighlights]; + otherFeedEvents = [...allOtherFeedEvents]; return; } @@ -150,6 +154,8 @@ if (!list) { listFilterIds = new Set(); posts = [...allPosts]; + highlights = [...allHighlights]; + otherFeedEvents = [...allOtherFeedEvents]; return; } @@ -179,22 +185,28 @@ listFilterIds = ids; - // Filter posts + // Filter posts, highlights, and other feed events if (kind === KIND.CONTACTS || kind === KIND.FOLOW_SET) { // Filter by author pubkey posts = allPosts.filter(post => ids.has(post.pubkey)); + highlights = allHighlights.filter(highlight => ids.has(highlight.pubkey)); + otherFeedEvents = allOtherFeedEvents.filter(event => ids.has(event.pubkey)); } else { // Filter by event ID posts = allPosts.filter(post => ids.has(post.id)); + highlights = allHighlights.filter(highlight => ids.has(highlight.id)); + otherFeedEvents = allOtherFeedEvents.filter((event: NostrEvent) => ids.has(event.id)); } } - // Apply filter when allPosts changes + // Apply filter when allPosts, allHighlights, or allOtherFeedEvents changes $effect(() => { if (selectedListId) { handleListFilterChange(selectedListId); } else { posts = [...allPosts]; + highlights = [...allHighlights]; + otherFeedEvents = [...allOtherFeedEvents]; } }); @@ -261,10 +273,8 @@ } const relays = relayManager.getFeedReadRelays(); - const filters = [ - { kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 }, - { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 20 } - ]; + const feedKinds = getFeedKinds(); + const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 })); // Subscribe to new kind 1 events and kind 9802 highlights subscriptionId = nostrClient.subscribe( @@ -367,56 +377,109 @@ // Use single relay if provided, otherwise use normal relay list const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); - // For single relay mode, ensure the relay is connected first - if (singleRelay) { - console.log(`[FeedPage] Single relay mode: ensuring ${singleRelay} is connected...`); - try { - // Force connection to the relay - await nostrClient.addRelay(singleRelay); - // Give it a moment to establish connection - await new Promise(resolve => setTimeout(resolve, 500)); - } catch (error) { - console.warn(`[FeedPage] Failed to connect to relay ${singleRelay}:`, error); - } - } - - // For single relay mode, disable cache completely - const useCache = !singleRelay; - const cacheResults = !singleRelay; - - // Load initial feed - use cache for fast initial load (unless single relay mode) // Load both kind 1 posts and kind 9802 highlights const filters = [ { kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 }, { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 20 } ]; - const events = await nostrClient.fetchEvents( + + // For single relay mode, load from cache first for immediate display + // Then query the relay in background to get fresh data + let events: NostrEvent[] = []; + + if (singleRelay) { + // Step 1: Load from cache immediately (fast, shows something right away) + const cachedEvents = await nostrClient.fetchEvents( + filters, + relays, + { + useCache: true, // Use cache for fast initial load + cacheResults: false, // Don't cache again + timeout: 2000 // Short timeout for cache + } + ); + + // Show cached data immediately if available + if (cachedEvents.length > 0) { + events = cachedEvents; + console.log(`[FeedPage] Loaded ${cachedEvents.length} cached events from ${singleRelay}`); + // Process cached events immediately so they show up + // (will be processed below) + } + + // Step 2: Ensure relay is connected and query for fresh data + // If we have cached data, do this in background. Otherwise, wait for it. + const queryPromise = (async () => { + try { + console.log(`[FeedPage] Single relay mode: ensuring ${singleRelay} is connected...`); + // Force connection to the relay + await nostrClient.addRelay(singleRelay); + // Give it a moment to establish connection + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Query relay for fresh data + const freshEvents = await nostrClient.fetchEvents( + filters, + relays, + { + useCache: false, // Force query relay + cacheResults: true, // Cache the results + timeout: 15000 + } + ); + + console.log(`[FeedPage] Fresh query returned ${freshEvents.length} events from ${singleRelay}`); + + // Update with fresh data + if (freshEvents.length > 0) { + const existingIds = new Set([...posts.map(p => p.id), ...highlights.map(h => h.id)]); + const trulyNew = freshEvents.filter(e => !existingIds.has(e.id)); + if (trulyNew.length > 0 || freshEvents.length !== events.length) { + handleUpdate(freshEvents); + } + } + + return freshEvents; + } catch (error) { + console.warn(`[FeedPage] Failed to query relay ${singleRelay}:`, error); + // If query fails but we have cached data, that's okay - keep showing cached data + return []; + } + })(); + + // If we don't have cached data, wait for the relay query + if (events.length === 0) { + const freshEvents = await queryPromise; + if (freshEvents.length > 0) { + events = freshEvents; + } + } else { + // If we have cached data, query in background (don't await) + queryPromise.catch(() => { + // Already logged error above + }); + } + } else { + // Normal mode: use cache first, then query relays + events = await nostrClient.fetchEvents( filters, relays, { - useCache, // Disable cache for single relay mode - cacheResults, // Don't cache results for single relay mode - // Don't use onUpdate here - subscriptions handle updates - timeout: 15000 // Longer timeout for single relay mode + useCache: true, // Use cache for fast initial load + cacheResults: true, // Cache results + timeout: 15000 } ); - console.log(`[FeedPage] Loaded ${events.length} events from ${singleRelay ? 'single relay' : 'relays'}`); - - // Separate posts and highlights - const postsList = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE); - const highlightsList = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE); + console.log(`[FeedPage] Loaded ${events.length} events from relays`); // Also immediately query relays to ensure we get fresh data in background - // For single relay mode, also do a background query to ensure we get results - if (!singleRelay || events.length === 0) { nostrClient.fetchEvents( filters, relays, { useCache: false, // Force query relays - cacheResults: !singleRelay, // Don't cache for single relay mode - // Don't use onUpdate - let subscriptions handle it + cacheResults: true, // Cache results timeout: 15000 } ).then((newEvents) => { @@ -434,6 +497,16 @@ }); } + // Separate events by kind - we'll handle all showInFeed kinds + const postsList = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE); + const highlightsList = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE); + // Store other feed kinds separately for now (we'll create cards for them) + const newOtherFeedEvents = events.filter((e: NostrEvent) => + e.kind !== KIND.SHORT_TEXT_NOTE && + e.kind !== KIND.HIGHLIGHTED_ARTICLE && + getKindInfo(e.kind).showInFeed === true + ); + // Sort by created_at descending and deduplicate const uniquePostsMap = new Map(); for (const event of postsList) { @@ -454,16 +527,33 @@ const uniqueHighlights = Array.from(uniqueHighlightsMap.values()); const sortedHighlights = uniqueHighlights.sort((a, b) => b.created_at - a.created_at); allHighlights = sortedHighlights; - highlights = [...allHighlights]; - // Always set posts, even if empty + // Store other feed events + const uniqueOtherMap = new Map(); + for (const event of newOtherFeedEvents) { + if (!uniqueOtherMap.has(event.id)) { + uniqueOtherMap.set(event.id, event); + } + } + const uniqueOther = Array.from(uniqueOtherMap.values()); + const sortedOther = uniqueOther.sort((a, b) => b.created_at - a.created_at); + allOtherFeedEvents = sortedOther; + + // Always set posts, highlights, and other feed events immediately, even if empty + // This ensures cached data shows up right away // Apply filter if one is selected if (selectedListId) { handleListFilterChange(selectedListId); } else { posts = [...allPosts]; + highlights = [...allHighlights]; + otherFeedEvents = [...allOtherFeedEvents]; } + // Set loading to false immediately after showing cached data + // This allows the UI to render while fresh data loads in background + loading = false; + console.log(`[FeedPage] Loaded ${sortedPosts.length} posts and ${sortedHighlights.length} highlights`); if (sortedPosts.length > 0 || sortedHighlights.length > 0) { @@ -493,15 +583,11 @@ // Use single relay if provided, otherwise use normal relay list const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); - // For single relay mode, disable cache completely - const useCache = !singleRelay; - const cacheResults = !singleRelay; - const filters = [ { - kinds: [KIND.SHORT_TEXT_NOTE], - limit: 20, - until: oldestTimestamp || undefined + kinds: [KIND.SHORT_TEXT_NOTE], + limit: 20, + until: oldestTimestamp || undefined }, { kinds: [KIND.HIGHLIGHTED_ARTICLE], @@ -510,35 +596,85 @@ } ]; - const events = await nostrClient.fetchEvents( + // For single relay mode, try cache first, then query relay + let events: NostrEvent[] = []; + + if (singleRelay) { + // Try cache first + const cachedEvents = await nostrClient.fetchEvents( filters, relays, { - useCache, // Disable cache for single relay mode - cacheResults, // Don't cache results for single relay mode + useCache: true, + cacheResults: false, + timeout: 2000 + } + ); + + if (cachedEvents.length > 0) { + events = cachedEvents; + } + + // Query relay in background for fresh data + nostrClient.fetchEvents( + filters, + relays, + { + useCache: false, + cacheResults: true, + timeout: 10000 + } + ).then((freshEvents) => { + if (freshEvents.length > 0) { + const existingIds = new Set([...allPosts.map(p => p.id), ...allHighlights.map(h => h.id)]); + const uniqueNewPosts = freshEvents.filter(e => e.kind === KIND.SHORT_TEXT_NOTE && !existingIds.has(e.id)); + const uniqueNewHighlights = freshEvents.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE && !existingIds.has(e.id)); + + if (uniqueNewPosts.length > 0 || uniqueNewHighlights.length > 0) { + handleUpdate(freshEvents); + } + } + }).catch(error => { + console.warn('[FeedPage] Background query error:', error); + }); + } else { + events = await nostrClient.fetchEvents( + filters, + relays, + { + useCache: true, + cacheResults: true, timeout: 10000 } ); + } if (events.length === 0) { hasMore = false; return; } - // Separate posts and highlights + // Separate events by kind const newPosts = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE); const newHighlights = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE); + const newOtherFeedEvents = events.filter((e: NostrEvent) => + e.kind !== KIND.SHORT_TEXT_NOTE && + e.kind !== KIND.HIGHLIGHTED_ARTICLE && + getKindInfo(e.kind).showInFeed === true + ); // Filter out duplicates const existingPostIds = new Set(allPosts.map(p => p.id)); const existingHighlightIds = new Set(allHighlights.map(h => h.id)); + const existingOtherIds = new Set(allOtherFeedEvents.map((e: NostrEvent) => e.id)); const uniqueNewPosts = newPosts.filter(e => !existingPostIds.has(e.id)); const uniqueNewHighlights = newHighlights.filter(e => !existingHighlightIds.has(e.id)); + const uniqueNewOther = newOtherFeedEvents.filter((e: NostrEvent) => !existingOtherIds.has(e.id)); if (uniqueNewPosts.length > 0 || uniqueNewHighlights.length > 0) { if (uniqueNewPosts.length > 0) { const sorted = uniqueNewPosts.sort((a, b) => b.created_at - a.created_at); - allPosts = [...allPosts, ...sorted]; + allPosts = [...allPosts, ...sorted]; // Batch load reactions for new posts await loadReactionsForPosts(sorted); } @@ -546,7 +682,12 @@ if (uniqueNewHighlights.length > 0) { const sorted = uniqueNewHighlights.sort((a, b) => b.created_at - a.created_at); allHighlights = [...allHighlights, ...sorted]; - highlights = [...allHighlights]; + } + + if (uniqueNewOther.length > 0) { + const sorted = uniqueNewOther.sort((a, b) => b.created_at - a.created_at); + allOtherFeedEvents = [...allOtherFeedEvents, ...sorted]; + otherFeedEvents = [...allOtherFeedEvents]; } // Apply filter if one is selected @@ -554,6 +695,8 @@ handleListFilterChange(selectedListId); } else { posts = [...allPosts]; + highlights = [...allHighlights]; + otherFeedEvents = [...allOtherFeedEvents]; } const allNewTimestamps = [...uniqueNewPosts.map(e => e.created_at), ...uniqueNewHighlights.map(e => e.created_at)]; @@ -626,9 +769,14 @@ console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${allPosts.length}`); - // Separate posts and highlights + // Separate events by kind const newPosts = newEvents.filter(e => e.kind === KIND.SHORT_TEXT_NOTE); const newHighlights = newEvents.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE); + const newOtherFeedEvents = newEvents.filter((e: NostrEvent) => + e.kind !== KIND.SHORT_TEXT_NOTE && + e.kind !== KIND.HIGHLIGHTED_ARTICLE && + getKindInfo(e.kind).showInFeed === true + ); // Merge and sort posts, then deduplicate by ID if (newPosts.length > 0) { @@ -637,12 +785,12 @@ for (const event of mergedPosts) { if (event && event.id && !uniquePostsMap.has(event.id)) { uniquePostsMap.set(event.id, event); - } } + } const uniquePosts = Array.from(uniquePostsMap.values()); const sortedPosts = uniquePosts.sort((a, b) => b.created_at - a.created_at); - - // Only update if we actually have new events to prevent loops + + // Only update if we actually have new events to prevent loops if (sortedPosts.length > allPosts.length || sortedPosts.some((e, i) => e.id !== allPosts[i]?.id)) { allPosts = sortedPosts; } @@ -663,17 +811,36 @@ // Only update if we actually have new events to prevent loops if (sortedHighlights.length > allHighlights.length || sortedHighlights.some((e, i) => e.id !== allHighlights[i]?.id)) { allHighlights = sortedHighlights; - highlights = [...allHighlights]; } } - - // Apply filter if one is selected - if (selectedListId) { - handleListFilterChange(selectedListId); - } else { - posts = [...allPosts]; + + // Merge and sort other feed events, then deduplicate by ID + if (newOtherFeedEvents.length > 0) { + const mergedOther = [...allOtherFeedEvents, ...newOtherFeedEvents]; + const uniqueOtherMap = new Map(); + for (const event of mergedOther) { + if (event && event.id && !uniqueOtherMap.has(event.id)) { + uniqueOtherMap.set(event.id, event); + } + } + const uniqueOther = Array.from(uniqueOtherMap.values()); + const sortedOther = uniqueOther.sort((a, b) => b.created_at - a.created_at); + + // Only update if we actually have new events to prevent loops + if (sortedOther.length > allOtherFeedEvents.length || sortedOther.some((e, i) => e.id !== allOtherFeedEvents[i]?.id)) { + allOtherFeedEvents = sortedOther; + } } - + + // Apply filter if one is selected + if (selectedListId) { + handleListFilterChange(selectedListId); + } else { + posts = [...allPosts]; + highlights = [...allHighlights]; + otherFeedEvents = [...allOtherFeedEvents]; + } + console.debug(`[FeedPage] Updated: ${allPosts.length} posts, ${allHighlights.length} highlights`); pendingUpdates = []; diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index 58d8703..1df641a 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -12,6 +12,7 @@ import type { NostrEvent } from '../../types/nostr.js'; import { getKindInfo, KIND } from '../../types/kind-lookup.js'; import { stripMarkdown } from '../../services/text-utils.js'; + import { isBookmarked } from '../../services/user-actions.js'; interface Props { post: NostrEvent; @@ -34,6 +35,15 @@ let needsExpansion = $state(false); let zapCount = $state(0); + // Check if this event is bookmarked (async, so we use state) + let bookmarked = $state(false); + + $effect(() => { + isBookmarked(post.id).then(b => { + bookmarked = b; + }); + }); + // Calculate votes as derived values to avoid infinite loops // Deduplicate by pubkey - each user should only count once per vote type let upvotes = $derived.by(() => { @@ -446,7 +456,8 @@ {/each} {/if} {/if} -
+
+ 🔖
@@ -663,4 +674,16 @@ vertical-align: middle; } + .bookmark-indicator { + display: inline-block; + font-size: 1rem; + line-height: 1; + filter: grayscale(100%); + transition: filter 0.2s; + } + + .bookmark-indicator.bookmarked { + filter: grayscale(0%); + } + diff --git a/src/lib/modules/feed/HighlightCard.svelte b/src/lib/modules/feed/HighlightCard.svelte index e1c6eb7..e56b5ea 100644 --- a/src/lib/modules/feed/HighlightCard.svelte +++ b/src/lib/modules/feed/HighlightCard.svelte @@ -8,6 +8,7 @@ import type { NostrEvent } from '../../types/nostr.js'; import { getKindInfo, KIND } from '../../types/kind-lookup.js'; import { getHighlightsForEvent } from '../../services/nostr/highlight-service.js'; + import { isBookmarked } from '../../services/user-actions.js'; interface Props { highlight: NostrEvent; // The highlight event (kind 9802) @@ -18,6 +19,15 @@ let sourceEvent = $state(null); let loadingSource = $state(false); + + // Check if this event is bookmarked (async, so we use state) + let bookmarked = $state(false); + + $effect(() => { + isBookmarked(highlight.id).then(b => { + bookmarked = b; + }); + }); // Extract source event ID from e-tag or a-tag function getSourceEventId(): string | null { @@ -37,6 +47,86 @@ return null; } + // Extract context tag value + function getContext(): string | null { + const contextTag = highlight.tags.find(t => t[0] === 'context' && t[1]); + return contextTag?.[1] || null; + } + + // Normalize text for matching (remove extra whitespace, normalize line breaks) + function normalizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); + } + + // Escape HTML to prevent XSS + function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // Find content within context and highlight it + function getHighlightedContext(): string { + const context = getContext(); + const content = highlight.content?.trim(); + + // If no context or no content, just return the content + if (!context || !content) { + return escapeHtml(content || ''); + } + + // Escape the context for safety + const escapedContext = escapeHtml(context); + const escapedContent = escapeHtml(content); + + // Normalize whitespace for matching (but preserve original for display) + const normalizeForMatch = (text: string) => text.replace(/\s+/g, ' ').trim(); + const normalizedContext = normalizeForMatch(context); + const normalizedContent = normalizeForMatch(content); + + // Try to find the normalized content within the normalized context + const normalizedIndex = normalizedContext.toLowerCase().indexOf(normalizedContent.toLowerCase()); + + if (normalizedIndex === -1) { + // Content not found in context, just return context + return escapedContext; + } + + // Find the actual position in the original context + // We need to map from normalized position back to original position + // This is approximate - we'll search for the content in the original context + // using a flexible regex that handles whitespace variations + + // Create a regex pattern from the content that allows flexible whitespace + const contentWords = content.trim().split(/\s+/); + const flexiblePattern = contentWords.map(word => + word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ).join('\\s+'); + + const regex = new RegExp(`(${flexiblePattern})`, 'i'); + const match = context.match(regex); + + if (!match) { + // Fallback: just return context without highlighting + return escapedContext; + } + + // Found match, highlight it + const matchIndex = match.index!; + const matchText = match[1]; + const before = escapeHtml(context.substring(0, matchIndex)); + const highlighted = '' + escapeHtml(matchText) + ''; + const after = escapeHtml(context.substring(matchIndex + matchText.length)); + + return before + highlighted + after; + } + + // Check if we should show context with highlight + let shouldShowContext = $derived(getContext() !== null && highlight.content?.trim() !== ''); + onMount(async () => { await loadSourceEvent(); }); @@ -147,14 +237,19 @@ {#if getClientName()} via {getClientName()} {/if} -
+
+ 🔖
- + {#if shouldShowContext} + {@html getHighlightedContext()} + {:else} + + {/if}
{#if sourceEvent} @@ -299,4 +394,29 @@ font-size: 0.625rem; opacity: 0.8; } + + :global(.highlight-content mark.highlight-text) { + background-color: #fef3c7; + color: #92400e; + padding: 0.125rem 0.25rem; + border-radius: 0.125rem; + font-weight: 500; + } + + :global(.dark .highlight-content mark.highlight-text) { + background-color: #78350f; + color: #fef3c7; + } + + .bookmark-indicator { + display: inline-block; + font-size: 1rem; + line-height: 1; + filter: grayscale(100%); + transition: filter 0.2s; + } + + .bookmark-indicator.bookmarked { + filter: grayscale(0%); + } diff --git a/src/lib/modules/profiles/PaymentAddresses.svelte b/src/lib/modules/profiles/PaymentAddresses.svelte index 7bfb40f..498afb3 100644 --- a/src/lib/modules/profiles/PaymentAddresses.svelte +++ b/src/lib/modules/profiles/PaymentAddresses.svelte @@ -3,6 +3,7 @@ import { fetchProfile } from '../../services/user-data.js'; import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; + import { KIND } from '../../types/kind-lookup.js'; interface Props { pubkey: string; @@ -11,6 +12,7 @@ let { pubkey }: Props = $props(); let paymentAddresses = $state>([]); + let paymentEvent = $state(null); let loading = $state(true); const recognizedTypes = ['bitcoin', 'lightning', 'ethereum', 'nano', 'monero', 'cashme', 'revolut', 'venmo']; @@ -37,8 +39,8 @@ // Extract from kind 10133 if (paymentEvents.length > 0) { - const event = paymentEvents[0]; - for (const tag of event.tags) { + paymentEvent = paymentEvents[0]; + for (const tag of paymentEvent.tags) { if (tag[0] === 'payto' && tag[1] && tag[2]) { const key = `${tag[1]}:${tag[2]}`; if (!seen.has(key)) { @@ -86,9 +88,16 @@
{#if loading} Loading payment addresses... - {:else if paymentAddresses.length > 0} + {:else if paymentAddresses.length > 0 || paymentEvent}

Payment Addresses

+ {#if paymentEvent} +
+ + From kind {KIND.PAYMENT_ADDRESSES} event + +
+ {/if} {#each paymentAddresses as { type, address }}
{getTypeLabel(type)}: diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 6b4157d..0dc05d1 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -5,6 +5,9 @@ import FeedPost from '../feed/FeedPost.svelte'; import ThreadDrawer from '../feed/ThreadDrawer.svelte'; import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte'; + import ProfileMenu from '../../components/profile/ProfileMenu.svelte'; + import BookmarksPanel from '../../components/profile/BookmarksPanel.svelte'; + import { getPinnedEvents } from '../../services/user-actions.js'; import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; @@ -21,8 +24,10 @@ let responses = $state([]); let interactionsWithMe = $state([]); let loading = $state(true); - let activeTab = $state<'posts' | 'responses' | 'interactions'>('posts'); + let activeTab = $state<'posts' | 'responses' | 'interactions' | 'pins'>('posts'); let nip05Validations = $state>({}); // null = checking, true = valid, false = invalid + // Compute pubkey from route params + let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey)); // Cache for NIP-05 validation results (nip05+pubkey -> result) // This prevents re-validating the same NIP-05 address repeatedly @@ -38,6 +43,12 @@ // Profile events panel state let profileEventsPanelOpen = $state(false); + // Bookmarks panel state + let bookmarksPanelOpen = $state(false); + + // Pins state + let pins = $state([]); + function openDrawer(event: NostrEvent) { drawerEvent = event; drawerOpen = true; @@ -56,6 +67,14 @@ profileEventsPanelOpen = false; } + function openBookmarksPanel() { + bookmarksPanelOpen = true; + } + + function closeBookmarksPanel() { + bookmarksPanelOpen = false; + } + const isOwnProfile = $derived.by(() => { const pubkey = decodePubkey($page.params.pubkey); return currentUserPubkey && pubkey && currentUserPubkey === pubkey; @@ -79,6 +98,29 @@ return unsubscribe; }); + async function loadPins(pubkey: string) { + try { + const pinnedIds = await getPinnedEvents(); + if (pinnedIds.size === 0) { + pins = []; + return; + } + + const profileRelays = relayManager.getProfileReadRelays(); + const pinnedEvents = await nostrClient.fetchEvents( + [{ ids: Array.from(pinnedIds), limit: 100 }], + profileRelays, + { useCache: true, cacheResults: true, timeout: 5000 } + ); + + // Sort by created_at descending + pins = pinnedEvents.sort((a, b) => b.created_at - a.created_at); + } catch (error) { + console.error('Error loading pins:', error); + pins = []; + } + } + async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) { if (!currentUserPubkey || currentUserPubkey === profilePubkey) { interactionsWithMe = []; @@ -399,6 +441,13 @@ } else { interactionsWithMe = []; } + + // Step 4: Load pins if viewing own profile + if (isOwnProfile) { + loadPins(pubkey); + } else { + pins = []; + } } catch (error) { console.error('Error loading profile:', error); loading = false; @@ -471,6 +520,16 @@ {/each}
{/if} + + {#if profilePubkey} +
+
+ {nip19.npubEncode(profilePubkey)} + +
+
+ {/if} + {#if isOwnProfile} @@ -507,6 +566,14 @@ Interactions with me ({interactionsWithMe.length}) {/if} + {#if isOwnProfile} + + {/if}
{#if activeTab === 'posts'} @@ -539,6 +606,16 @@ {/each}
{/if} + {:else if activeTab === 'pins'} + {#if pins.length === 0} +

No pinned posts yet.

+ {:else} +
+ {#each pins as pin (pin.id)} + + {/each} +
+ {/if} {/if} {:else} @@ -553,6 +630,10 @@ pubkey={decodePubkey($page.params.pubkey) || ''} onClose={closeProfileEventsPanel} /> + {/if} @@ -679,4 +760,32 @@ .adjust-profile-button:hover { opacity: 0.9; } + + .profile-npub-section { + margin-top: 0.5rem; + } + + .npub-display { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .npub-text { + font-family: monospace; + font-size: 0.875rem; + color: var(--fog-text-light, #6b7280); + word-break: break-all; + padding: 0.25rem 0.5rem; + background: var(--fog-highlight, #f3f4f6); + border-radius: 0.25rem; + border: 1px solid var(--fog-border, #e5e7eb); + } + + :global(.dark) .npub-text { + color: var(--fog-dark-text-light, #9ca3af); + background: var(--fog-dark-highlight, #374151); + border-color: var(--fog-dark-border, #475569); + } diff --git a/src/lib/services/cache/deletion-tracker.ts b/src/lib/services/cache/deletion-tracker.ts index 7056f60..88dcf93 100644 --- a/src/lib/services/cache/deletion-tracker.ts +++ b/src/lib/services/cache/deletion-tracker.ts @@ -18,9 +18,14 @@ async function getDeletedEventIdsFromCache(): Promise> { const tx = db.transaction('events', 'readonly'); const index = tx.store.index('kind'); - // Get all kind 5 (deletion request) events - for await (const cursor of index.iterate(KIND.EVENT_DELETION)) { - const deletionEvent = cursor.value as CachedEvent; + // Use getAll() instead of iterate to avoid transaction timing issues + const deletionEvents = await index.getAll(KIND.EVENT_DELETION); + + // Wait for transaction to complete + await tx.done; + + // Process deletion events + for (const deletionEvent of deletionEvents) { // Extract event IDs from 'e' tags if (deletionEvent.tags) { for (const tag of deletionEvent.tags) { @@ -30,8 +35,6 @@ async function getDeletedEventIdsFromCache(): Promise> { } } } - - await tx.done; } catch (error) { console.debug('Error getting deleted event IDs from cache:', error); } diff --git a/src/lib/services/cache/event-cache.ts b/src/lib/services/cache/event-cache.ts index df79dfc..999fc0a 100644 --- a/src/lib/services/cache/event-cache.ts +++ b/src/lib/services/cache/event-cache.ts @@ -39,7 +39,7 @@ export async function cacheEvents(events: NostrEvent[]): Promise { try { if (events.length === 0) return; - // Check which events are marked as deleted + // Check which events are marked as deleted (complete this transaction first) const eventIds = events.map(e => e.id); const deletedIds = await getDeletedEventIds(eventIds); @@ -48,15 +48,22 @@ export async function cacheEvents(events: NostrEvent[]): Promise { if (eventsToCache.length === 0) return; + // Create a new transaction for writing (after the read transaction is complete) const db = await getDB(); const tx = db.transaction('events', 'readwrite'); - for (const event of eventsToCache) { - const cached: CachedEvent = { + + // Prepare all cached events first + const cachedEvents: CachedEvent[] = eventsToCache.map(event => ({ ...event, cached_at: Date.now() - }; - await tx.store.put(cached); + })); + + // Put all events in a single batch + for (const cached of cachedEvents) { + tx.store.put(cached); } + + // Wait for transaction to complete await tx.done; } catch (error) { console.debug('Error caching events:', error); diff --git a/src/lib/services/user-actions.ts b/src/lib/services/user-actions.ts index 25ff465..b407fc3 100644 --- a/src/lib/services/user-actions.ts +++ b/src/lib/services/user-actions.ts @@ -1,6 +1,6 @@ /** * User actions service - manages pinned, bookmarked, and highlighted events - * Stores in localStorage for persistence and publishes list events + * All data stored as events in IndexedDB cache and published to relays */ import { sessionManager } from './auth/session-manager.js'; @@ -10,103 +10,186 @@ import { relayManager } from './nostr/relay-manager.js'; import { KIND } from '../types/kind-lookup.js'; import type { NostrEvent } from '../types/nostr.js'; -const STORAGE_KEY_PINNED = 'aitherboard_pinned_events'; -const STORAGE_KEY_BOOKMARKED = 'aitherboard_bookmarked_events'; -const STORAGE_KEY_HIGHLIGHTED = 'aitherboard_highlighted_events'; - /** - * Get all pinned event IDs + * Get all pinned event IDs from published kind 10001 event (from cache/relays) */ -export function getPinnedEvents(): Set { - if (typeof window === 'undefined') return new Set(); +export async function getPinnedEvents(): Promise> { + const pinnedIds = new Set(); + try { - const stored = localStorage.getItem(STORAGE_KEY_PINNED); - if (!stored) return new Set(); - const ids = JSON.parse(stored) as string[]; - return new Set(ids); - } catch { - return new Set(); + const session = sessionManager.getSession(); + if (!session) return pinnedIds; + + // Fetch published pin list event from cache/relays + const relays = relayManager.getProfileReadRelays(); + const pinLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.PIN_LIST], authors: [session.pubkey], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + + // Extract event IDs from published pin list + if (pinLists.length > 0) { + const pinList = pinLists[0]; + for (const tag of pinList.tags) { + if (tag[0] === 'e' && tag[1]) { + pinnedIds.add(tag[1]); + } + } + } + } catch (error) { + console.debug('Error fetching pinned events:', error); } + + return pinnedIds; } /** - * Get all bookmarked event IDs + * Get all bookmarked event IDs from published kind 10003 event (from cache/relays) */ -export function getBookmarkedEvents(): Set { - if (typeof window === 'undefined') return new Set(); +export async function getBookmarkedEvents(): Promise> { + const bookmarkedIds = new Set(); + try { - const stored = localStorage.getItem(STORAGE_KEY_BOOKMARKED); - if (!stored) return new Set(); - const ids = JSON.parse(stored) as string[]; - return new Set(ids); - } catch { - return new Set(); + const session = sessionManager.getSession(); + if (!session) return bookmarkedIds; + + // Fetch published bookmark list event from cache/relays + const relays = relayManager.getProfileReadRelays(); + const bookmarkLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.BOOKMARKS], authors: [session.pubkey], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + + // Extract event IDs from published bookmark list + if (bookmarkLists.length > 0) { + const bookmarkList = bookmarkLists[0]; + for (const tag of bookmarkList.tags) { + if (tag[0] === 'e' && tag[1]) { + bookmarkedIds.add(tag[1]); + } + // Note: a-tags would need to be resolved to get event IDs + // For now, we only support e-tags + } + } + } catch (error) { + console.debug('Error fetching bookmarked events:', error); } + + return bookmarkedIds; } /** - * Get all highlighted event IDs + * Get all highlighted event IDs (highlights are stored as kind 9802 events, not in a list) + * This function is kept for compatibility but highlights are actually stored as events */ -export function getHighlightedEvents(): Set { - if (typeof window === 'undefined') return new Set(); - try { - const stored = localStorage.getItem(STORAGE_KEY_HIGHLIGHTED); - if (!stored) return new Set(); - const ids = JSON.parse(stored) as string[]; - return new Set(ids); - } catch { - return new Set(); +export async function getHighlightedEvents(): Promise> { + // Highlights are stored as kind 9802 events, not in a list + // This function is kept for compatibility but returns empty set + // To get highlights, query for kind 9802 events by the user's pubkey + return new Set(); +} + +// Cache for pinned events to avoid repeated async calls +let pinnedCache: Set | null = null; +let pinnedCacheTime: number = 0; +const PINNED_CACHE_TTL = 5000; // 5 seconds + +/** + * Check if an event is pinned (uses cached result if available) + */ +export async function isPinned(eventId: string): Promise { + const now = Date.now(); + if (pinnedCache && (now - pinnedCacheTime) < PINNED_CACHE_TTL) { + return pinnedCache.has(eventId); } + + pinnedCache = await getPinnedEvents(); + pinnedCacheTime = now; + return pinnedCache.has(eventId); } /** - * Check if an event is pinned + * Invalidate pin cache (call after toggling pins) */ -export function isPinned(eventId: string): boolean { - return getPinnedEvents().has(eventId); +function invalidatePinCache() { + pinnedCache = null; + pinnedCacheTime = 0; +} + +// Cache for bookmarked events to avoid repeated async calls +let bookmarkedCache: Set | null = null; +let bookmarkedCacheTime: number = 0; +const BOOKMARKED_CACHE_TTL = 5000; // 5 seconds + +/** + * Check if an event is bookmarked (uses cached result if available) + */ +export async function isBookmarked(eventId: string): Promise { + const now = Date.now(); + if (bookmarkedCache && (now - bookmarkedCacheTime) < BOOKMARKED_CACHE_TTL) { + return bookmarkedCache.has(eventId); + } + + bookmarkedCache = await getBookmarkedEvents(); + bookmarkedCacheTime = now; + return bookmarkedCache.has(eventId); } /** - * Check if an event is bookmarked + * Invalidate bookmark cache (call after toggling bookmarks) */ -export function isBookmarked(eventId: string): boolean { - return getBookmarkedEvents().has(eventId); +function invalidateBookmarkCache() { + bookmarkedCache = null; + bookmarkedCacheTime = 0; } /** * Check if an event is highlighted + * Highlights are stored as kind 9802 events, not in a list + * This function is kept for compatibility but always returns false */ export function isHighlighted(eventId: string): boolean { - return getHighlightedEvents().has(eventId); + // Highlights are stored as kind 9802 events, not in a list + // To check if an event is highlighted, query for kind 9802 events that reference it + return false; } /** * Toggle pin status of an event - * Updates localStorage and publishes kind 10001 list event + * Publishes kind 10001 list event (pins are stored in cache and on relays only) */ export async function togglePin(eventId: string): Promise { - const pinned = getPinnedEvents(); - const isCurrentlyPinned = pinned.has(eventId); - - if (isCurrentlyPinned) { - pinned.delete(eventId); - } else { - pinned.add(eventId); - } - try { - localStorage.setItem(STORAGE_KEY_PINNED, JSON.stringify(Array.from(pinned))); - - // Publish list event if user is logged in const session = sessionManager.getSession(); - if (session) { - await publishPinList(Array.from(pinned)); + if (!session) { + throw new Error('Not logged in'); + } + + // Get current pins from published event + const currentPins = await getPinnedEvents(); + const isCurrentlyPinned = currentPins.has(eventId); + + // Toggle the pin + if (isCurrentlyPinned) { + currentPins.delete(eventId); + } else { + currentPins.add(eventId); } + // Publish updated pin list event + await publishPinList(Array.from(currentPins)); + + // Invalidate cache so next read gets fresh data + invalidatePinCache(); + return !isCurrentlyPinned; } catch (error) { - console.error('Failed to save pinned events:', error); - return isCurrentlyPinned; + console.error('Failed to toggle pin:', error); + // Return current state on error + const currentPins = await getPinnedEvents(); + return currentPins.has(eventId); } } @@ -218,31 +301,38 @@ async function publishPinList(eventIds: string[]): Promise { /** * Toggle bookmark status of an event - * Updates localStorage and publishes kind 10003 list event + * Publishes kind 10003 list event (bookmarks are stored in cache and on relays only) */ export async function toggleBookmark(eventId: string): Promise { - const bookmarked = getBookmarkedEvents(); - const isCurrentlyBookmarked = bookmarked.has(eventId); - - if (isCurrentlyBookmarked) { - bookmarked.delete(eventId); - } else { - bookmarked.add(eventId); - } - try { - localStorage.setItem(STORAGE_KEY_BOOKMARKED, JSON.stringify(Array.from(bookmarked))); - - // Publish list event if user is logged in const session = sessionManager.getSession(); - if (session) { - await publishBookmarkList(Array.from(bookmarked)); + if (!session) { + throw new Error('Not logged in'); } + // Get current bookmarks from published event + const currentBookmarks = await getBookmarkedEvents(); + const isCurrentlyBookmarked = currentBookmarks.has(eventId); + + // Toggle the bookmark + if (isCurrentlyBookmarked) { + currentBookmarks.delete(eventId); + } else { + currentBookmarks.add(eventId); + } + + // Publish updated bookmark list event + await publishBookmarkList(Array.from(currentBookmarks)); + + // Invalidate cache so next read gets fresh data + invalidateBookmarkCache(); + return !isCurrentlyBookmarked; } catch (error) { - console.error('Failed to save bookmarked events:', error); - return isCurrentlyBookmarked; + console.error('Failed to toggle bookmark:', error); + // Return current state on error + const currentBookmarks = await getBookmarkedEvents(); + return currentBookmarks.has(eventId); } } @@ -354,22 +444,347 @@ async function publishBookmarkList(eventIds: string[]): Promise { /** * Toggle highlight status of an event + * Highlights are stored as kind 9802 events, not in a list + * This function is kept for compatibility but does nothing */ export function toggleHighlight(eventId: string): boolean { - const highlighted = getHighlightedEvents(); - const isCurrentlyHighlighted = highlighted.has(eventId); + // Highlights are stored as kind 9802 events, not in a list + // To create a highlight, publish a kind 9802 event + // This function is kept for compatibility but does nothing + return false; +} + +/** + * Get all muted pubkeys from published kind 10000 event (from cache/relays) + */ +export async function getMutedPubkeys(): Promise> { + const mutedPubkeys = new Set(); + + try { + const session = sessionManager.getSession(); + if (!session) return mutedPubkeys; + + // Fetch published mute list event from cache/relays + const relays = relayManager.getProfileReadRelays(); + const muteLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.MUTE_LIST], authors: [session.pubkey], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + + // Extract pubkeys from published mute list + if (muteLists.length > 0) { + const muteList = muteLists[0]; + for (const tag of muteList.tags) { + if (tag[0] === 'p' && tag[1]) { + mutedPubkeys.add(tag[1]); + } + } + } + } catch (error) { + console.debug('Error fetching muted pubkeys:', error); + } + + return mutedPubkeys; +} + +// Cache for muted pubkeys to avoid repeated async calls +let mutedCache: Set | null = null; +let mutedCacheTime: number = 0; +const MUTED_CACHE_TTL = 5000; // 5 seconds + +/** + * Check if a pubkey is muted (uses cached result if available) + */ +export async function isMuted(pubkey: string): Promise { + const now = Date.now(); + if (mutedCache && (now - mutedCacheTime) < MUTED_CACHE_TTL) { + return mutedCache.has(pubkey); + } - if (isCurrentlyHighlighted) { - highlighted.delete(eventId); + mutedCache = await getMutedPubkeys(); + mutedCacheTime = now; + return mutedCache.has(pubkey); +} + +/** + * Invalidate mute cache (call after toggling mutes) + */ +function invalidateMuteCache() { + mutedCache = null; + mutedCacheTime = 0; +} + +/** + * Toggle mute status of a user + * Publishes kind 10000 mute list event (mutes are stored in cache and on relays only) + */ +export async function toggleMute(pubkey: string): Promise { + try { + const session = sessionManager.getSession(); + if (!session) { + throw new Error('Not logged in'); + } + + // Get current mutes from published event + const currentMutes = await getMutedPubkeys(); + const isCurrentlyMuted = currentMutes.has(pubkey); + + // Toggle the mute + if (isCurrentlyMuted) { + currentMutes.delete(pubkey); } else { - highlighted.add(eventId); + currentMutes.add(pubkey); + } + + // Publish updated mute list event + await publishMuteList(Array.from(currentMutes)); + + // Invalidate cache so next read gets fresh data + invalidateMuteCache(); + + return !isCurrentlyMuted; + } catch (error) { + console.error('Failed to toggle mute:', error); + // Return current state on error + const currentMutes = await getMutedPubkeys(); + return currentMutes.has(pubkey); + } +} + +/** + * Publish mute list event (kind 10000) + */ +async function publishMuteList(pubkeys: string[]): Promise { + try { + const session = sessionManager.getSession(); + if (!session) return; + + // Deduplicate pubkeys + const deduplicatedPubkeys = Array.from(new Set(pubkeys)); + + // Fetch existing mute list to merge with new entries + const relays = relayManager.getProfileReadRelays(); + const existingLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.MUTE_LIST], authors: [session.pubkey], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + + // Collect existing p tags + const existingPubkeys = new Set(); + + if (existingLists.length > 0) { + const existingList = existingLists[0]; + for (const tag of existingList.tags) { + if (tag[0] === 'p' && tag[1]) { + existingPubkeys.add(tag[1]); + } + } + } + + // Check if we have any changes + const newPubkeys = deduplicatedPubkeys.filter(p => !existingPubkeys.has(p)); + const removedPubkeys = [...existingPubkeys].filter(p => !deduplicatedPubkeys.includes(p)); + + if (newPubkeys.length === 0 && removedPubkeys.length === 0 && existingLists.length > 0) { + return; // No changes, cancel operation + } + + // Build final tags: all p tags for muted pubkeys + const tags: string[][] = []; + for (const pubkey of deduplicatedPubkeys) { + tags.push(['p', pubkey]); + } + + // Create new mute list event + const listEvent: Omit = { + kind: KIND.MUTE_LIST, + pubkey: session.pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: tags, + content: '' + }; + + // Publish to write relays + const writeRelays = relayManager.getPublishRelays(relays, true); + await signAndPublish(listEvent, writeRelays); + } catch (error) { + console.error('Failed to publish mute list:', error); } +} + +/** + * Get all followed pubkeys from published kind 3 event (from cache/relays) + */ +export async function getFollowedPubkeys(): Promise> { + const followedPubkeys = new Set(); try { - localStorage.setItem(STORAGE_KEY_HIGHLIGHTED, JSON.stringify(Array.from(highlighted))); - return !isCurrentlyHighlighted; + const session = sessionManager.getSession(); + if (!session) return followedPubkeys; + + // Fetch published follow list event (kind 3) from cache/relays + const relays = relayManager.getProfileReadRelays(); + const followLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.CONTACTS], authors: [session.pubkey], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + + // Extract pubkeys from published follow list + if (followLists.length > 0) { + const followList = followLists[0]; + for (const tag of followList.tags) { + if (tag[0] === 'p' && tag[1]) { + followedPubkeys.add(tag[1]); + } + } + } + } catch (error) { + console.debug('Error fetching followed pubkeys:', error); + } + + return followedPubkeys; +} + +// Cache for followed pubkeys to avoid repeated async calls +let followedCache: Set | null = null; +let followedCacheTime: number = 0; +const FOLLOWED_CACHE_TTL = 5000; // 5 seconds + +/** + * Check if a pubkey is followed (uses cached result if available) + */ +export async function isFollowed(pubkey: string): Promise { + const now = Date.now(); + if (followedCache && (now - followedCacheTime) < FOLLOWED_CACHE_TTL) { + return followedCache.has(pubkey); + } + + followedCache = await getFollowedPubkeys(); + followedCacheTime = now; + return followedCache.has(pubkey); +} + +/** + * Invalidate follow cache (call after toggling follows) + */ +function invalidateFollowCache() { + followedCache = null; + followedCacheTime = 0; +} + +/** + * Toggle follow status of a user + * Publishes kind 3 follow list event (follows are stored in cache and on relays only) + */ +export async function toggleFollow(pubkey: string): Promise { + try { + const session = sessionManager.getSession(); + if (!session) { + throw new Error('Not logged in'); + } + + // Get current follows from published event + const currentFollows = await getFollowedPubkeys(); + const isCurrentlyFollowed = currentFollows.has(pubkey); + + // Toggle the follow + if (isCurrentlyFollowed) { + currentFollows.delete(pubkey); + } else { + currentFollows.add(pubkey); + } + + // Publish updated follow list event + await publishFollowList(Array.from(currentFollows)); + + // Invalidate cache so next read gets fresh data + invalidateFollowCache(); + + return !isCurrentlyFollowed; + } catch (error) { + console.error('Failed to toggle follow:', error); + // Return current state on error + const currentFollows = await getFollowedPubkeys(); + return currentFollows.has(pubkey); + } +} + +/** + * Publish follow list event (kind 3) + */ +async function publishFollowList(pubkeys: string[]): Promise { + try { + const session = sessionManager.getSession(); + if (!session) return; + + // Deduplicate pubkeys + const deduplicatedPubkeys = Array.from(new Set(pubkeys)); + + // Fetch existing follow list to merge with new entries + const relays = relayManager.getProfileReadRelays(); + const existingLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.CONTACTS], authors: [session.pubkey], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + + // Collect existing p tags + const existingPubkeys = new Set(); + const existingPTags: string[][] = []; // Store full p tags to preserve relay hints and petnames + + if (existingLists.length > 0) { + const existingList = existingLists[0]; + for (const tag of existingList.tags) { + if (tag[0] === 'p' && tag[1]) { + existingPubkeys.add(tag[1]); + existingPTags.push(tag); + } + } + } + + // Check if we have any changes + const newPubkeys = deduplicatedPubkeys.filter(p => !existingPubkeys.has(p)); + const removedPubkeys = [...existingPubkeys].filter(p => !deduplicatedPubkeys.includes(p)); + + if (newPubkeys.length === 0 && removedPubkeys.length === 0 && existingLists.length > 0) { + return; // No changes, cancel operation + } + + // Build final tags: preserve existing p tags for pubkeys we're keeping, add new ones + const tags: string[][] = []; + const seenPubkeys = new Set(); + + // First, add existing p tags for pubkeys we're keeping + for (const tag of existingPTags) { + if (tag[1] && deduplicatedPubkeys.includes(tag[1])) { + tags.push(tag); + seenPubkeys.add(tag[1]); + } + } + + // Then, add new p tags for pubkeys we're adding (without relay hints or petnames) + for (const pubkey of deduplicatedPubkeys) { + if (!seenPubkeys.has(pubkey)) { + tags.push(['p', pubkey]); + } + } + + // Create new follow list event + const listEvent: Omit = { + kind: KIND.CONTACTS, + pubkey: session.pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: tags, + content: '' + }; + + // Publish to write relays + const writeRelays = relayManager.getPublishRelays(relays, true); + await signAndPublish(listEvent, writeRelays); } catch (error) { - console.error('Failed to save highlighted events:', error); - return isCurrentlyHighlighted; + console.error('Failed to publish follow list:', error); } } diff --git a/src/lib/types/kind-lookup.ts b/src/lib/types/kind-lookup.ts index 3c5007c..b02960c 100644 --- a/src/lib/types/kind-lookup.ts +++ b/src/lib/types/kind-lookup.ts @@ -95,8 +95,8 @@ export const KIND = { export const KIND_LOOKUP: Record = { // Core kinds - [KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true }, - [KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: true, isSecondaryKind: false }, + [KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true, isSecondaryKind: false }, + [KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: false, isSecondaryKind: false }, [KIND.EVENT_DELETION]: { number: KIND.EVENT_DELETION, description: 'Event Deletion', showInFeed: false, isSecondaryKind: false }, [KIND.REACTION]: { number: KIND.REACTION, description: 'Reaction', showInFeed: false, isSecondaryKind: true }, @@ -105,7 +105,7 @@ export const KIND_LOOKUP: Record = { [KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: true, isSecondaryKind: false }, // Threads and comments - [KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isSecondaryKind: false }, + [KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: true, isSecondaryKind: false }, [KIND.COMMENT]: { number: KIND.COMMENT, description: 'Comment', showInFeed: true, isSecondaryKind: true }, // Media @@ -113,7 +113,7 @@ export const KIND_LOOKUP: Record = { [KIND.VIDEO_NOTE]: { number: KIND.VIDEO_NOTE, description: 'Video Note', showInFeed: true, isSecondaryKind: false }, [KIND.SHORT_VIDEO_NOTE]: { number: KIND.SHORT_VIDEO_NOTE, description: 'Short Video Note', showInFeed: true, isSecondaryKind: false }, [KIND.VOICE_NOTE]: { number: KIND.VOICE_NOTE, description: 'Voice Note (Yak)', showInFeed: true, isSecondaryKind: false }, - [KIND.VOICE_REPLY]: { number: KIND.VOICE_REPLY, description: 'Voice Reply (Yak Back)', showInFeed: false, isSecondaryKind: true }, + [KIND.VOICE_REPLY]: { number: KIND.VOICE_REPLY, description: 'Voice Reply (Yak Back)', showInFeed: true, isSecondaryKind: false }, [KIND.FILE_METADATA]: { number: KIND.FILE_METADATA, description: 'File Metadata (GIFs)', showInFeed: false, isSecondaryKind: false }, // Polls