Browse Source

bug-fix

master
Silberengel 1 month ago
parent
commit
2b93cf051b
  1. 4
      public/healthz.json
  2. 53
      src/app.css
  3. 2
      src/lib/components/content/VoteCount.svelte
  4. 55
      src/lib/modules/discussions/DiscussionList.svelte
  5. 58
      src/lib/modules/discussions/DiscussionVoteButtons.svelte
  6. 10
      src/lib/modules/events/EventView.svelte
  7. 10
      src/lib/modules/feed/FeedPost.svelte
  8. 55
      src/lib/modules/profiles/ProfilePage.svelte
  9. 16
      src/routes/event/[id]/+page.svelte
  10. 905
      src/routes/find/+page.svelte
  11. 27
      src/routes/lists/+page.svelte
  12. 51
      src/routes/topics/[name]/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.2.0", "version": "0.2.0",
"buildTime": "2026-02-06T19:28:59.767Z", "buildTime": "2026-02-06T23:46:07.275Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770406139768 "timestamp": 1770421567275
} }

53
src/app.css

@ -11,6 +11,7 @@
--content-width: 800px; --content-width: 800px;
} }
/* Base text size preferences - will be overridden by media queries if not specified */
[data-text-size='small'] { [data-text-size='small'] {
--text-size: 10px; --text-size: 10px;
} }
@ -70,23 +71,67 @@ body {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
} }
/* Responsive font sizing based on screen size */ /* Responsive font sizing based on screen size - defaults only */
@media (max-width: 640px) { @media (max-width: 640px) {
:root { :root:not([data-text-size]) {
--text-size: clamp(14px, 4vw, 16px); --text-size: clamp(14px, 4vw, 16px);
} }
} }
@media (min-width: 641px) and (max-width: 1024px) { @media (min-width: 641px) and (max-width: 1024px) {
:root { :root:not([data-text-size]) {
--text-size: clamp(15px, 1.5vw, 18px); --text-size: clamp(15px, 1.5vw, 18px);
} }
} }
@media (min-width: 1025px) { @media (min-width: 1025px) {
:root { :root:not([data-text-size]) {
--text-size: clamp(16px, 1.2vw, 20px);
}
}
/* User text size preferences - override media queries with higher specificity */
/* These come after media queries to ensure they take precedence */
@media (max-width: 640px) {
[data-text-size='small'] {
--text-size: clamp(12px, 3.5vw, 14px);
}
[data-text-size='medium'] {
--text-size: clamp(14px, 4vw, 16px);
}
[data-text-size='large'] {
--text-size: clamp(16px, 4.5vw, 18px);
}
}
@media (min-width: 641px) and (max-width: 1024px) {
[data-text-size='small'] {
--text-size: clamp(13px, 1.3vw, 16px);
}
[data-text-size='medium'] {
--text-size: clamp(15px, 1.5vw, 18px);
}
[data-text-size='large'] {
--text-size: clamp(17px, 1.7vw, 20px);
}
}
@media (min-width: 1025px) {
[data-text-size='small'] {
--text-size: clamp(14px, 1.1vw, 18px);
}
[data-text-size='medium'] {
--text-size: clamp(16px, 1.2vw, 20px); --text-size: clamp(16px, 1.2vw, 20px);
} }
[data-text-size='large'] {
--text-size: clamp(18px, 1.3vw, 22px);
}
} }
/* Paragraph spacing - increased for better readability */ /* Paragraph spacing - increased for better readability */

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

@ -93,9 +93,11 @@
color: inherit; color: inherit;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 0.25rem; gap: 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
opacity: 0.7; opacity: 0.7;
min-width: 2.5rem; /* Ensure consistent button width */
} }
.vote-emoji.disabled { .vote-emoji.disabled {

55
src/lib/modules/discussions/DiscussionList.svelte

@ -100,30 +100,34 @@
}; };
}); });
// Only reload when sortBy, showOlder, or selectedTopic changes (after initial values are set) // Only reload when sortBy or selectedTopic changes (after initial values are set)
// Note: showOlder only affects view filtering, not data fetching
$effect(() => { $effect(() => {
// Skip if we haven't set initial values yet (onMount hasn't run) // Skip if we haven't set initial values yet (onMount hasn't run)
if (prevSortBy === null) return; if (prevSortBy === null) return;
// Read showOlder to ensure it's tracked by the effect // Read showOlder to ensure it's tracked by the effect (for view filtering)
const currentShowOlder = showOlder; const currentShowOlder = showOlder;
const currentSortBy = sortBy; const currentSortBy = sortBy;
const currentSelectedTopic = selectedTopic; const currentSelectedTopic = selectedTopic;
// Check if any filter parameter actually changed // Check if sortBy or topic changed (showOlder only affects filtering, not fetching)
if (currentSortBy !== prevSortBy || currentShowOlder !== prevShowOlder || currentSelectedTopic !== prevSelectedTopic) { const sortByChanged = currentSortBy !== prevSortBy;
prevSortBy = currentSortBy; const topicChanged = currentSelectedTopic !== prevSelectedTopic;
prevShowOlder = currentShowOlder;
prevSelectedTopic = currentSelectedTopic; if (sortByChanged || topicChanged) {
// Only reload if not already loading // Only reload if not already loading
if (!isLoading) { if (!isLoading) {
// If showOlder changed, also reload from cache to get older threads
if (currentShowOlder !== prevShowOlder) {
loadCachedThreads();
}
loadAllData(); loadAllData();
} }
// Update previous values AFTER checking for changes
prevSortBy = currentSortBy;
prevShowOlder = currentShowOlder; // Track but don't reload on change
prevSelectedTopic = currentSelectedTopic;
} else {
// Update showOlder tracking even if nothing else changed
prevShowOlder = currentShowOlder;
} }
}); });
@ -131,24 +135,13 @@
if (!isMounted || isLoading) return; if (!isMounted || isLoading) return;
try { try {
const config = nostrClient.getConfig(); // Always load all cached threads (limit 100), filtering happens in the view
const since = showOlder const cachedThreads = await getRecentCachedEvents([KIND.DISCUSSION_THREAD], 15 * 60 * 1000, 100);
? undefined
: Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
// Load cached threads (within 15 minutes)
const cachedThreads = await getRecentCachedEvents([KIND.DISCUSSION_THREAD], 15 * 60 * 1000, 50);
if (cachedThreads.length > 0 && isMounted) { if (cachedThreads.length > 0 && isMounted) {
// Filter by time if needed // Build threads map from cached results (no time filtering here)
let filtered = cachedThreads;
if (since) {
filtered = cachedThreads.filter(e => e.created_at >= since);
}
// Build threads map from cached results
const newThreadsMap = new Map<string, NostrEvent>(); const newThreadsMap = new Map<string, NostrEvent>();
for (const event of filtered) { for (const event of cachedThreads) {
newThreadsMap.set(event.id, event); newThreadsMap.set(event.id, event);
} }
@ -174,9 +167,8 @@
voteCountsReady = false; // Reset vote counts ready state voteCountsReady = false; // Reset vote counts ready state
try { try {
const config = nostrClient.getConfig(); const config = nostrClient.getConfig();
const since = showOlder // Always fetch limit 100 without time filter - filtering happens in the view
? undefined // No 'since' parameter - fetch all threads
: Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
const threadRelays = relayManager.getThreadReadRelays(); const threadRelays = relayManager.getThreadReadRelays();
// Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays // Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays
@ -185,8 +177,9 @@
const commentRelays = relayManager.getCommentReadRelays(); const commentRelays = relayManager.getCommentReadRelays();
// Query relays first with 3-second timeout, then fill from cache if needed // Query relays first with 3-second timeout, then fill from cache if needed
// Always fetch limit 100, no time filtering
const fetchPromise = nostrClient.fetchEvents( const fetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.DISCUSSION_THREAD], since, limit: config.feedLimit }], [{ kinds: [KIND.DISCUSSION_THREAD], limit: 100 }],
threadRelays, threadRelays,
{ {
useCache: 'relay-first', // Query relays first with timeout, fill from cache if relay query returns nothing useCache: 'relay-first', // Query relays first with timeout, fill from cache if relay query returns nothing

58
src/lib/modules/discussions/DiscussionVoteButtons.svelte

@ -65,22 +65,37 @@
if (event.id) { if (event.id) {
// Use pre-loaded reactions if available, otherwise fetch // Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) { if (preloadedReactions && preloadedReactions.length > 0) {
const filtered = await filterDeletedReactions(preloadedReactions); try {
const filtered = await filterDeletedReactions(preloadedReactions);
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5 // Update the map to only contain non-deleted reactions
const filteredMap = new Map<string, NostrEvent>(); // Reassign map to trigger reactivity in Svelte 5
for (const reaction of filtered) { const filteredMap = new Map<string, NostrEvent>();
filteredMap.set(reaction.id, reaction); for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filtered);
initialLoadComplete = true; // Mark initial load as complete
} catch (error) {
console.error('[DiscussionVoteButtons] Error processing preloaded reactions:', error);
initialLoadComplete = true; // Mark as complete even on error
} finally {
loading = false; // Always set loading to false
} }
allReactionsMap = filteredMap;
processReactions(filtered);
initialLoadComplete = true; // Mark initial load as complete
} else { } else {
loadReactions(); loadReactions();
} }
} else {
// No event ID, mark as complete
loading = false;
initialLoadComplete = true;
} }
}).catch((error) => {
console.error('[DiscussionVoteButtons] Error initializing client:', error);
loading = false;
initialLoadComplete = true; // Mark as complete even on error
}); });
}); });
@ -98,6 +113,7 @@
// Use pre-loaded reactions if available, otherwise fetch // Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) { if (preloadedReactions && preloadedReactions.length > 0) {
loading = true; // Set loading while processing
const newMap = new Map<string, NostrEvent>(); const newMap = new Map<string, NostrEvent>();
for (const r of preloadedReactions) { for (const r of preloadedReactions) {
newMap.set(r.id, r); newMap.set(r.id, r);
@ -114,6 +130,11 @@
processReactions(filtered); processReactions(filtered);
initialLoadComplete = true; // Mark initial load as complete initialLoadComplete = true; // Mark initial load as complete
loading = false; // Set loading to false after processing
}).catch((error) => {
console.error('[DiscussionVoteButtons] Error processing preloaded reactions in effect:', error);
initialLoadComplete = true; // Mark as complete even on error
loading = false; // Always set loading to false
}); });
} else { } else {
loadReactions(); loadReactions();
@ -185,6 +206,10 @@
async function loadReactions() { async function loadReactions() {
// Prevent concurrent loads for the same event // Prevent concurrent loads for the same event
if (loadingReactions) { if (loadingReactions) {
// If already loading, ensure loading state is set correctly
if (!loading) {
loading = true;
}
return; return;
} }
loadingReactions = true; loadingReactions = true;
@ -440,7 +465,16 @@
} }
} }
const isLoggedIn = $derived(sessionManager.isLoggedIn()); // Subscribe to session changes for reactive login state
let currentSession = $state(sessionManager.session.value);
let isLoggedIn = $derived(currentSession !== null);
$effect(() => {
const unsubscribe = sessionManager.session.subscribe((session) => {
currentSession = session;
});
return unsubscribe;
});
// Get user's current vote (normalize to + or -) // Get user's current vote (normalize to + or -)
let userVote = $derived<string | null>(userReaction === '+' || userReaction === '-' ? userReaction : null); let userVote = $derived<string | null>(userReaction === '+' || userReaction === '-' ? userReaction : null);

10
src/lib/modules/events/EventView.svelte

@ -40,6 +40,7 @@
let rootEvent = $state<NostrEvent | null>(null); let rootEvent = $state<NostrEvent | null>(null);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let lastLoadedEventId = $state<string | null>(null); // Track last loaded eventId to prevent loops
// For kind 30040: event index hierarchy // For kind 30040: event index hierarchy
let isEventIndex = $derived(rootEvent?.kind === 30040); let isEventIndex = $derived(rootEvent?.kind === 30040);
@ -80,17 +81,24 @@
}); });
$effect(() => { $effect(() => {
if (eventId) { // Only load if eventId changed and we're not already loading
if (eventId && eventId !== lastLoadedEventId && !loading) {
loadEvent(); loadEvent();
} }
}); });
async function loadEvent() { async function loadEvent() {
// Prevent loading if already loading the same event
if (loading && lastLoadedEventId === eventId) {
return;
}
loading = true; loading = true;
error = null; error = null;
indexError = null; indexError = null;
eventIndexItems = []; eventIndexItems = [];
rootEvent = null; rootEvent = null;
lastLoadedEventId = eventId; // Track that we're loading this eventId
try { try {
const threadRelays = relayManager.getThreadReadRelays(); const threadRelays = relayManager.getThreadReadRelays();

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

@ -887,16 +887,6 @@
</h2> </h2>
{/if} {/if}
<!-- Show reply-to blurb in feed view -->
{#if isReply()}
<ReplyContext
parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
parentEventTagType={getReplyTagType() || undefined}
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined}
/>
{/if}
<!-- Show referenced event preview in feed view --> <!-- Show referenced event preview in feed view -->
<ReferencedEventPreview event={post} preloadedReferencedEvent={preloadedReferencedEvent} /> <ReferencedEventPreview event={post} preloadedReferencedEvent={preloadedReferencedEvent} />

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

@ -4,7 +4,6 @@
import PaymentAddresses from './PaymentAddresses.svelte'; import PaymentAddresses from './PaymentAddresses.svelte';
import FeedPost from '../feed/FeedPost.svelte'; import FeedPost from '../feed/FeedPost.svelte';
import CommentComponent from '../comments/Comment.svelte'; import CommentComponent from '../comments/Comment.svelte';
import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte';
import ProfileMenu from '../../components/profile/ProfileMenu.svelte'; import ProfileMenu from '../../components/profile/ProfileMenu.svelte';
import { fetchProfile, fetchUserStatus, fetchUserStatusEvent, type ProfileData } from '../../services/user-data.js'; import { fetchProfile, fetchUserStatus, fetchUserStatusEvent, type ProfileData } from '../../services/user-data.js';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
@ -41,9 +40,6 @@
// Get current logged-in user's pubkey // Get current logged-in user's pubkey
let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey()); let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey());
// Profile events panel state
let profileEventsPanelOpen = $state(false);
// Pins state // Pins state
let pins = $state<NostrEvent[]>([]); let pins = $state<NostrEvent[]>([]);
@ -71,14 +67,6 @@
}; };
}); });
function openProfileEventsPanel() {
profileEventsPanelOpen = true;
}
function closeProfileEventsPanel() {
profileEventsPanelOpen = false;
}
const isOwnProfile = $derived.by(() => { const isOwnProfile = $derived.by(() => {
const pubkey = decodePubkey($page.params.pubkey); const pubkey = decodePubkey($page.params.pubkey);
return currentUserPubkey && pubkey && currentUserPubkey === pubkey; return currentUserPubkey && pubkey && currentUserPubkey === pubkey;
@ -804,17 +792,6 @@
{/if} {/if}
<PaymentAddresses pubkey={decodePubkey($page.params.pubkey) || ''} /> <PaymentAddresses pubkey={decodePubkey($page.params.pubkey) || ''} />
{#if isOwnProfile}
<div class="profile-actions mt-4">
<button
class="adjust-profile-button"
onclick={openProfileEventsPanel}
>
Adjust profile events
</button>
</div>
{/if}
</div> </div>
<div class="profile-posts"> <div class="profile-posts">
@ -949,14 +926,6 @@
{:else} {:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p>
{/if} {/if}
{#if isOwnProfile}
<ProfileEventsPanel
isOpen={profileEventsPanelOpen}
pubkey={decodePubkey($page.params.pubkey) || ''}
onClose={closeProfileEventsPanel}
/>
{/if}
</div> </div>
<style> <style>
@ -1059,30 +1028,6 @@
} }
} }
.profile-actions {
margin-top: 1rem;
}
.adjust-profile-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
:global(.dark) .adjust-profile-button {
background: var(--fog-dark-accent, #94a3b8);
}
.adjust-profile-button:hover {
opacity: 0.9;
}
.profile-npub-section { .profile-npub-section {
margin-top: 0.5rem; margin-top: 0.5rem;
} }

16
src/routes/event/[id]/+page.svelte

@ -13,6 +13,7 @@
let eventKind = $state<number | null>(null); let eventKind = $state<number | null>(null);
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let lastProcessedParam = $state<string | null>(null); // Track last processed param to prevent loops
/** /**
* Decode route parameter to event hex ID * Decode route parameter to event hex ID
@ -89,15 +90,22 @@
} }
async function loadEvent() { async function loadEvent() {
if (!$page.params.id) return; const currentParam = $page.params.id;
if (!currentParam) return;
// Prevent loading if already loading the same param
if (loading && lastProcessedParam === currentParam) {
return;
}
loading = true; loading = true;
error = null; error = null;
decodedEventId = null; decodedEventId = null;
eventKind = null; eventKind = null;
lastProcessedParam = currentParam; // Track that we're processing this param
try { try {
const eventId = await decodeEventId($page.params.id); const eventId = await decodeEventId(currentParam);
if (eventId) { if (eventId) {
decodedEventId = eventId; decodedEventId = eventId;
@ -126,7 +134,9 @@
}); });
$effect(() => { $effect(() => {
if ($page.params.id) { const currentParam = $page.params.id;
// Only load if param changed and we're not already loading
if (currentParam && currentParam !== lastProcessedParam && !loading) {
loadEvent(); loadEvent();
} }
}); });

905
src/routes/find/+page.svelte

@ -5,16 +5,16 @@
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import RelayBadge from '../../lib/components/layout/RelayBadge.svelte'; import RelayBadge from '../../lib/components/layout/RelayBadge.svelte';
import CacheBadge from '../../lib/components/layout/CacheBadge.svelte'; import CacheBadge from '../../lib/components/layout/CacheBadge.svelte';
import SearchAddressableEvents from '../../lib/components/find/SearchAddressableEvents.svelte';
import { KIND, KIND_LOOKUP } from '../../lib/types/kind-lookup.js'; import { KIND, KIND_LOOKUP } from '../../lib/types/kind-lookup.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { nip19 } from 'nostr-tools';
let selectedKind = $state<number | null>(null); let selectedKind = $state<number | null>(null);
let selectedKindString = $state<string>(''); let selectedKindString = $state<string>('');
let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }; clearSearch: () => void } | null = $state(null); let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }; clearSearch: () => void } | null = $state(null);
let addressableSearchComponent: { clearSearch: () => void; hasActiveSearch: () => boolean } | null = $state(null);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] }); let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
let cacheResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); let cacheResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
// Map to track which relay each event came from // Map to track which relay each event came from
@ -22,6 +22,22 @@
let searching = $state(false); let searching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = $state(null); let searchTimeout: ReturnType<typeof setTimeout> | null = $state(null);
// NIP-01 standard filter controls
let filterIds = $state<string>(''); // Comma-separated event IDs
let filterAuthors = $state<string>(''); // Comma-separated pubkeys
let filterKinds = $state<string>(''); // Comma-separated kind numbers
let filterE = $state<string>(''); // Comma-separated event IDs for #e tag
let filterP = $state<string>(''); // Comma-separated pubkeys for #p tag
let filterA = $state<string>(''); // Comma-separated addressable event refs for #a tag
let filterQ = $state<string>(''); // Comma-separated event IDs for #q tag (quoted events)
let filterT = $state<string>(''); // Comma-separated topic values for #T tag
let filterC = $state<string>(''); // Comma-separated category values for #C tag
let filterD = $state<string>(''); // Comma-separated d-tag values for #d tag
let filterSince = $state<string>(''); // Date-time string (will be converted to unix timestamp)
let filterUntil = $state<string>(''); // Date-time string (will be converted to unix timestamp)
let filterLimit = $state<number>(100); // Limit number
let showAdvancedFilters = $state(false); // Toggle for showing advanced filters
// Check if there are any active searches or results // Check if there are any active searches or results
const hasActiveSearch = $derived.by(() => { const hasActiveSearch = $derived.by(() => {
if (searching) return true; if (searching) return true;
@ -32,10 +48,6 @@
const filterResult = component.getFilterResult(); const filterResult = component.getFilterResult();
if (filterResult.value !== null) return true; if (filterResult.value !== null) return true;
} }
// Check SearchAddressableEvents component
if (addressableSearchComponent) {
if (addressableSearchComponent.hasActiveSearch()) return true;
}
return false; return false;
}); });
@ -61,22 +73,581 @@
selectedKindString = select.value; selectedKindString = select.value;
} }
function handleSearch() { // Helper function to decode bech32 to hex (for pubkeys and event IDs)
if (unifiedSearchComponent) { async function decodeBech32ToHex(bech32: string, type: 'pubkey' | 'event'): Promise<string | null> {
// Clear previous timeout try {
if (searchTimeout) { // Check if it's a bech32 format
clearTimeout(searchTimeout); if (!/^(npub|nprofile|note|nevent|naddr)1[a-z0-9]+$/i.test(bech32)) {
searchTimeout = null; return null;
}
const decoded = nip19.decode(bech32);
if (type === 'pubkey') {
if (decoded.type === 'npub') {
return String(decoded.data).toLowerCase();
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey).toLowerCase();
}
} else if (decoded.type === 'naddr') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey).toLowerCase();
}
}
} else if (type === 'event') {
if (decoded.type === 'note') {
return String(decoded.data).toLowerCase();
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
return String(decoded.data.id).toLowerCase();
}
}
}
} catch (error) {
// Not a valid bech32, return null
console.debug('Error decoding bech32:', error);
}
return null;
}
// Helper function to parse comma-separated values and validate hex strings
// Also decodes bech32 formats (npub, nprofile, note, nevent)
async function parseHexList(input: string, type: 'pubkey' | 'event' = 'event'): Promise<string[]> {
const results: string[] = [];
const parts = input.split(',').map(s => s.trim()).filter(s => s.length > 0);
for (const part of parts) {
// Check if it's already a hex string
if (/^[0-9a-f]{64}$/i.test(part)) {
results.push(part.toLowerCase());
} else {
// Try to decode as bech32
const decoded = await decodeBech32ToHex(part, type);
if (decoded) {
results.push(decoded);
}
}
}
return results;
}
// Helper function to parse comma-separated numbers
function parseNumberList(input: string): number[] {
return input
.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && n >= 0 && n <= 65535);
}
// Helper function to parse comma-separated addressable event refs (kind:pubkey:d-tag)
// Also decodes bech32 formats in the pubkey part (npub, nprofile, naddr)
async function parseAddressableList(input: string): Promise<string[]> {
const results: string[] = [];
const parts = input.split(',').map(s => s.trim()).filter(s => s.length > 0);
for (const part of parts) {
// Check if it matches the addressable format: kind:pubkey:d-tag or kind:pubkey:
const match = part.match(/^(\d+):([^:]+)(?::(.+))?$/);
if (match) {
const [, kind, pubkeyPart, dTag] = match;
let hexPubkey: string | null = null;
// Check if pubkey part is already hex
if (/^[0-9a-f]{64}$/i.test(pubkeyPart)) {
hexPubkey = pubkeyPart.toLowerCase();
} else {
// Try to decode as bech32
hexPubkey = await decodeBech32ToHex(pubkeyPart, 'pubkey');
}
if (hexPubkey) {
// Reconstruct the addressable format
if (dTag) {
results.push(`${kind}:${hexPubkey}:${dTag}`);
} else {
results.push(`${kind}:${hexPubkey}:`);
}
}
}
}
return results;
}
// Helper function to convert date-time string to unix timestamp
// Accepts ISO 8601 format (e.g., "2024-01-15T14:30:00Z" or "2024-01-15T14:30:00+05:00")
// Also accepts unix timestamp as fallback
function dateTimeToUnixTimestamp(dateTimeStr: string): number | null {
if (!dateTimeStr.trim()) return null;
try {
// Try parsing as ISO 8601 date-time string (supports timezone)
const date = new Date(dateTimeStr.trim());
// Check if date is valid
if (isNaN(date.getTime())) {
// If ISO parsing fails, try as unix timestamp (backward compatibility)
const timestamp = parseInt(dateTimeStr.trim(), 10);
if (!isNaN(timestamp) && timestamp > 0) {
return timestamp;
}
return null;
} }
// Convert to unix timestamp (seconds)
return Math.floor(date.getTime() / 1000);
} catch (error) {
// If parsing fails, try as unix timestamp (backward compatibility)
const timestamp = parseInt(dateTimeStr.trim(), 10);
if (!isNaN(timestamp) && timestamp > 0) {
return timestamp;
}
return null;
}
}
// Build NIP-01 filter from form inputs
async function buildFilter(): Promise<any> {
const filter: any = {};
// ids filter (event IDs)
if (filterIds.trim()) {
const ids = await parseHexList(filterIds, 'event');
if (ids.length > 0) {
filter.ids = ids;
}
}
// authors filter (pubkeys)
if (filterAuthors.trim()) {
const authors = await parseHexList(filterAuthors, 'pubkey');
if (authors.length > 0) {
filter.authors = authors;
}
}
// kinds filter - prioritize advanced filter, then selectedKind, then all kinds
if (filterKinds.trim()) {
const kinds = parseNumberList(filterKinds);
if (kinds.length > 0) {
filter.kinds = kinds;
}
} else if (selectedKind !== null) {
filter.kinds = [selectedKind];
}
// #e tag filter (event IDs)
if (filterE.trim()) {
const eTags = await parseHexList(filterE, 'event');
if (eTags.length > 0) {
filter['#e'] = eTags;
}
}
// #p tag filter (pubkeys)
if (filterP.trim()) {
const pTags = await parseHexList(filterP, 'pubkey');
if (pTags.length > 0) {
filter['#p'] = pTags;
}
}
// #a tag filter (addressable events)
if (filterA.trim()) {
const aTags = await parseAddressableList(filterA);
if (aTags.length > 0) {
filter['#a'] = aTags;
}
}
// #q tag filter (quoted events - event IDs)
if (filterQ.trim()) {
const qTags = await parseHexList(filterQ, 'event');
if (qTags.length > 0) {
filter['#q'] = qTags;
}
}
// #T tag filter (topics)
if (filterT.trim()) {
const tTags = filterT
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (tTags.length > 0) {
filter['#T'] = tTags;
}
}
// #C tag filter (categories)
if (filterC.trim()) {
const cTags = filterC
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (cTags.length > 0) {
filter['#C'] = cTags;
}
}
// #d tag filter (d-tags for addressable events)
if (filterD.trim()) {
const dTags = filterD
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (dTags.length > 0) {
filter['#d'] = dTags;
}
}
// since filter - convert date-time to unix timestamp
if (filterSince.trim()) {
const since = dateTimeToUnixTimestamp(filterSince);
if (since !== null && since > 0) {
filter.since = since;
}
}
// until filter - convert date-time to unix timestamp
if (filterUntil.trim()) {
const until = dateTimeToUnixTimestamp(filterUntil);
if (until !== null && until > 0) {
filter.until = until;
}
}
// limit filter
if (filterLimit > 0) {
filter.limit = filterLimit;
} else {
filter.limit = 100; // Default limit
}
return filter;
}
// Check if any advanced filters are set (including selectedKind)
function hasAdvancedFilters(): boolean {
return !!(
selectedKind !== null ||
filterIds.trim() ||
filterAuthors.trim() ||
filterKinds.trim() ||
filterE.trim() ||
filterP.trim() ||
filterA.trim() ||
filterQ.trim() ||
filterT.trim() ||
filterC.trim() ||
filterD.trim() ||
filterSince.trim() ||
filterUntil.trim() ||
filterLimit !== 100
);
}
// Check if there's any input (search term, kind, or advanced filters)
const hasAnyInput = $derived.by(() => {
if (!unifiedSearchComponent) return false;
const filterResult = unifiedSearchComponent.getFilterResult();
const hasSearchTerm = !!(filterResult.value && filterResult.value.trim());
return hasSearchTerm || hasAdvancedFilters();
});
async function handleSearch() {
if (!unifiedSearchComponent) return;
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
const filterResult = unifiedSearchComponent.getFilterResult();
const searchTerm = filterResult.value?.trim() || '';
const hasAdvanced = hasAdvancedFilters();
// If no search term, use NIP-01 filters
if (!searchTerm) {
searching = true; searching = true;
unifiedSearchComponent.triggerSearch();
// Set 10 second timeout try {
searchTimeout = setTimeout(() => { await nostrClient.initialize();
const relays = relayManager.getAllAvailableRelays();
const newEventRelayMap = new Map<string, string>();
// Build filter from form inputs
const filter = await buildFilter();
// If no filter conditions at all, default to all kinds with limit
if (Object.keys(filter).length === 1 && filter.limit) {
// Only limit specified, get all kinds
const allKindNumbers = Object.values(KIND_LOOKUP).map(k => k.number);
// Split into chunks to avoid too large filters
const chunkSize = 10;
const filters: any[] = [];
for (let i = 0; i < allKindNumbers.length; i += chunkSize) {
const chunk = allKindNumbers.slice(i, i + chunkSize);
filters.push({ kinds: chunk, limit: filter.limit });
}
// Fetch events
const allEvents: NostrEvent[] = [];
for (const f of filters) {
const events = await nostrClient.fetchEvents(
[f],
relays,
{ useCache: true, cacheResults: true }
);
for (const event of events) {
if (!newEventRelayMap.has(event.id)) {
newEventRelayMap.set(event.id, relays[0] || 'unknown');
}
allEvents.push(event);
}
}
// Deduplicate and sort
const uniqueEvents = Array.from(
new Map(allEvents.map(e => [e.id, e])).values()
).sort((a, b) => b.created_at - a.created_at);
const limitedEvents = uniqueEvents.slice(0, filter.limit);
// Update eventRelayMap
eventRelayMap.clear();
for (const [eventId, relay] of newEventRelayMap) {
eventRelayMap.set(eventId, relay);
}
// Update results
searchResults = {
events: limitedEvents,
profiles: [],
relays: relays
};
} else {
// Use the built filter
const events = await nostrClient.fetchEvents(
[filter],
relays,
{ useCache: true, cacheResults: true }
);
// Track which relay each event came from
for (const event of events) {
if (!newEventRelayMap.has(event.id)) {
newEventRelayMap.set(event.id, relays[0] || 'unknown');
}
}
// Update eventRelayMap
eventRelayMap.clear();
for (const [eventId, relay] of newEventRelayMap) {
eventRelayMap.set(eventId, relay);
}
// Update results
searchResults = {
events: events,
profiles: [],
relays: relays
};
}
searching = false; searching = false;
searchTimeout = null; } catch (error) {
}, 10000); console.error('Error searching with filters:', error);
searching = false;
searchResults = { events: [], profiles: [], relays: [] };
}
} else {
// Has search term - always combine with filters (kind dropdown + advanced filters)
searching = true;
try {
await nostrClient.initialize();
const relays = relayManager.getAllAvailableRelays();
const newEventRelayMap = new Map<string, string>();
// Build filter from all form inputs (includes selectedKind and advanced filters)
const filter = await buildFilter();
// Decode bech32 formats in search term and add to filter
let decodedPubkey: string | null = null;
let decodedEventId: string | null = null;
// Check if search term is a bech32 format (npub, nprofile, note, nevent, naddr)
if (/^(npub|nprofile|note|nevent|naddr)1[a-z0-9]+$/i.test(searchTerm)) {
try {
const decoded = nip19.decode(searchTerm);
if (decoded.type === 'npub') {
decodedPubkey = String(decoded.data).toLowerCase();
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
decodedPubkey = String(decoded.data.pubkey).toLowerCase();
}
} else if (decoded.type === 'note') {
decodedEventId = String(decoded.data).toLowerCase();
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
decodedEventId = String(decoded.data.id).toLowerCase();
}
} else if (decoded.type === 'naddr') {
// naddr encodes kind + pubkey + d tag, we can extract pubkey
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
decodedPubkey = String(decoded.data.pubkey).toLowerCase();
}
}
} catch (error) {
// Not a valid bech32, continue with text search
console.debug('Error decoding bech32:', error);
}
}
// Check if search term is a hex string (64 hex chars)
// Could be event ID or pubkey
if (!decodedPubkey && !decodedEventId && /^[0-9a-f]{64}$/i.test(searchTerm)) {
// Try to get event from cache first to determine if it's an event ID
const { getEvent } = await import('../../lib/services/cache/event-cache.js');
const hexLower = searchTerm.toLowerCase();
const cachedEvent = await getEvent(hexLower);
if (cachedEvent) {
// It's an event ID
decodedEventId = hexLower;
} else {
// Assume it's a pubkey (more common in searches)
decodedPubkey = hexLower;
}
}
// Add decoded pubkey to authors filter
if (decodedPubkey) {
if (filter.authors) {
// Add to existing authors if not already present
const authors = Array.isArray(filter.authors) ? filter.authors : [filter.authors];
if (!authors.includes(decodedPubkey)) {
filter.authors = [...authors, decodedPubkey];
}
} else {
filter.authors = [decodedPubkey];
}
}
// Add decoded event ID to ids filter
if (decodedEventId) {
if (filter.ids) {
// Add to existing ids if not already present
const ids = Array.isArray(filter.ids) ? filter.ids : [filter.ids];
if (!ids.includes(decodedEventId)) {
filter.ids = [...ids, decodedEventId];
}
} else {
filter.ids = [decodedEventId];
}
}
// Check if filter has meaningful conditions (not just limit)
const hasFilterConditions = Object.keys(filter).length > 1 ||
(filter.kinds && filter.kinds.length > 0) ||
filter.ids || filter.authors || filter['#e'] || filter['#p'] ||
filter['#a'] || filter['#q'] || filter['#T'] || filter['#C'] ||
filter['#d'] || filter.since || filter.until;
let eventsToSearch: NostrEvent[] = [];
if (hasFilterConditions) {
// Has filter conditions, use them
eventsToSearch = await nostrClient.fetchEvents(
[filter],
relays,
{ useCache: true, cacheResults: true }
);
} else {
// No filters at all, fall back to UnifiedSearch for better text search
unifiedSearchComponent.triggerSearch();
searchTimeout = setTimeout(() => {
searching = false;
searchTimeout = null;
}, 10000);
return;
}
// If we decoded a pubkey or event ID, we already filtered by that, so just return those events
// Otherwise, filter by text content client-side
let filteredEvents: NostrEvent[] = [];
if (decodedPubkey || decodedEventId) {
// Already filtered by pubkey/event ID, use results as-is
filteredEvents = eventsToSearch;
} else {
// Filter by text content client-side
const queryLower = searchTerm.toLowerCase();
filteredEvents = eventsToSearch.filter(event => {
// Check content
if (event.content.toLowerCase().includes(queryLower)) return true;
// Check title tag
const titleTag = event.tags.find(t => t[0] === 'title');
if (titleTag?.[1]?.toLowerCase().includes(queryLower)) return true;
// Check summary tag
const summaryTag = event.tags.find(t => t[0] === 'summary');
if (summaryTag?.[1]?.toLowerCase().includes(queryLower)) return true;
// Check if search term matches event ID or pubkey
if (event.id.toLowerCase().includes(queryLower)) return true;
if (event.pubkey.toLowerCase().includes(queryLower)) return true;
return false;
});
}
// Sort by relevance (exact content match first, then by created_at)
// Only do text-based sorting if we didn't decode a pubkey/event ID
const sortedEvents = decodedPubkey || decodedEventId
? filteredEvents.sort((a, b) => b.created_at - a.created_at)
: filteredEvents.sort((a, b) => {
const queryLower = searchTerm.toLowerCase();
const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return b.created_at - a.created_at;
});
// Track which relay each event came from
for (const event of sortedEvents) {
if (!newEventRelayMap.has(event.id)) {
newEventRelayMap.set(event.id, relays[0] || 'unknown');
}
}
// Update eventRelayMap
eventRelayMap.clear();
for (const [eventId, relay] of newEventRelayMap) {
eventRelayMap.set(eventId, relay);
}
// Update results
searchResults = {
events: sortedEvents,
profiles: [],
relays: relays
};
searching = false;
} catch (error) {
console.error('Error searching with combined filters:', error);
searching = false;
searchResults = { events: [], profiles: [], relays: [] };
}
} }
} }
@ -117,11 +688,6 @@
unifiedSearchComponent.clearSearch(); unifiedSearchComponent.clearSearch();
} }
// Clear SearchAddressableEvents
if (addressableSearchComponent) {
addressableSearchComponent.clearSearch();
}
// Clear local state // Clear local state
searchResults = { events: [], profiles: [] }; searchResults = { events: [], profiles: [] };
cacheResults = { events: [], profiles: [] }; cacheResults = { events: [], profiles: [] };
@ -130,12 +696,28 @@
selectedKind = null; selectedKind = null;
selectedKindString = ''; selectedKindString = '';
// Clear filter inputs
filterIds = '';
filterAuthors = '';
filterKinds = '';
filterE = '';
filterP = '';
filterA = '';
filterQ = '';
filterT = '';
filterC = '';
filterD = '';
filterSince = '';
filterUntil = '';
filterLimit = 100;
// Clear timeouts // Clear timeouts
if (searchTimeout) { if (searchTimeout) {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = null; searchTimeout = null;
} }
} }
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
@ -148,7 +730,7 @@
<div class="find-page"> <div class="find-page">
<div class="page-header"> <div class="page-header">
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Find</h1> <h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Find</h1>
{#if hasActiveSearch} {#if hasAnyInput}
<button <button
class="clear-button" class="clear-button"
onclick={clearAllSearches} onclick={clearAllSearches}
@ -202,6 +784,167 @@
{searching ? 'Searching...' : 'Search'} {searching ? 'Searching...' : 'Search'}
</button> </button>
</div> </div>
<div class="advanced-filters-section">
<button
type="button"
class="toggle-advanced-filters"
onclick={() => showAdvancedFilters = !showAdvancedFilters}
aria-label="Toggle advanced filters"
>
{showAdvancedFilters ? '▼' : '▶'} Advanced Filters (NIP-01)
</button>
{#if showAdvancedFilters}
<div class="advanced-filters-grid">
<div class="filter-group">
<label for="filter-ids" class="filter-label">IDs (comma-separated event IDs):</label>
<input
id="filter-ids"
type="text"
bind:value={filterIds}
placeholder="64-char hex event IDs"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-authors" class="filter-label">Authors (comma-separated pubkeys):</label>
<input
id="filter-authors"
type="text"
bind:value={filterAuthors}
placeholder="64-char hex pubkeys"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-kinds" class="filter-label">Kinds (comma-separated, overrides kind dropdown):</label>
<input
id="filter-kinds"
type="text"
bind:value={filterKinds}
placeholder="e.g., 1, 7, 11"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-e" class="filter-label">#e tag (comma-separated event IDs):</label>
<input
id="filter-e"
type="text"
bind:value={filterE}
placeholder="64-char hex event IDs"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-p" class="filter-label">#p tag (comma-separated pubkeys):</label>
<input
id="filter-p"
type="text"
bind:value={filterP}
placeholder="64-char hex pubkeys"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-a" class="filter-label">#a tag (comma-separated addressable refs):</label>
<input
id="filter-a"
type="text"
bind:value={filterA}
placeholder="kind:pubkey:d-tag"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-q" class="filter-label">#q tag (comma-separated quoted event IDs):</label>
<input
id="filter-q"
type="text"
bind:value={filterQ}
placeholder="64-char hex event IDs"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-t" class="filter-label">#T tag (comma-separated topics):</label>
<input
id="filter-t"
type="text"
bind:value={filterT}
placeholder="topic1, topic2, topic3"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-c" class="filter-label">#C tag (comma-separated categories):</label>
<input
id="filter-c"
type="text"
bind:value={filterC}
placeholder="category1, category2, category3"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-d" class="filter-label">#d tag (comma-separated d-tag values):</label>
<input
id="filter-d"
type="text"
bind:value={filterD}
placeholder="d-tag1, d-tag2, d-tag3"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-since" class="filter-label">Since (date-time or unix timestamp):</label>
<input
id="filter-since"
type="text"
bind:value={filterSince}
placeholder="2024-01-15T14:30:00Z or unix timestamp"
class="filter-input"
/>
<small class="filter-hint">Accepts ISO 8601 format (e.g., 2024-01-15T14:30:00Z, 2024-01-15T14:30:00+05:00) or unix timestamp</small>
</div>
<div class="filter-group">
<label for="filter-until" class="filter-label">Until (date-time or unix timestamp):</label>
<input
id="filter-until"
type="text"
bind:value={filterUntil}
placeholder="2024-01-15T14:30:00Z or unix timestamp"
class="filter-input"
/>
<small class="filter-hint">Accepts ISO 8601 format (e.g., 2024-01-15T14:30:00Z, 2024-01-15T14:30:00+05:00) or unix timestamp</small>
</div>
<div class="filter-group">
<label for="filter-limit" class="filter-label">Limit:</label>
<input
id="filter-limit"
type="number"
bind:value={filterLimit}
min="1"
max="1000"
class="filter-input"
/>
</div>
</div>
{/if}
</div>
</div> </div>
</section> </section>
@ -289,10 +1032,6 @@
</div> </div>
</section> </section>
{/if} {/if}
<section class="find-section">
<SearchAddressableEvents bind:this={addressableSearchComponent} />
</section>
</div> </div>
</div> </div>
</main> </main>
@ -672,4 +1411,116 @@
:global(.dark) .no-results { :global(.dark) .no-results {
color: var(--fog-dark-text-light, #9ca3af); color: var(--fog-dark-text-light, #9ca3af);
} }
.advanced-filters-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .advanced-filters-section {
border-top-color: var(--fog-dark-border, #374151);
}
.toggle-advanced-filters {
background: none;
border: none;
color: var(--fog-accent, #64748b);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 0;
text-align: left;
width: 100%;
transition: color 0.2s;
}
:global(.dark) .toggle-advanced-filters {
color: var(--fog-dark-accent, #94a3b8);
}
.toggle-advanced-filters:hover {
color: var(--fog-text, #475569);
}
:global(.dark) .toggle-advanced-filters:hover {
color: var(--fog-dark-text, #cbd5e1);
}
.advanced-filters-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1rem;
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.375rem;
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .advanced-filters-grid {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
@media (min-width: 768px) {
.advanced-filters-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.filter-input {
padding: 0.5rem;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: inherit;
width: 100%;
}
.filter-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .filter-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .filter-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.filter-hint {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
margin-top: 0.25rem;
display: block;
}
:global(.dark) .filter-hint {
color: var(--fog-dark-text-light, #9ca3af);
}
</style> </style>

27
src/routes/lists/+page.svelte

@ -142,7 +142,22 @@
const relays = getAllRelays(); const relays = getAllRelays();
const feedKinds = getFeedKinds(); // Get all kinds with showInFeed: true const feedKinds = getFeedKinds(); // Get all kinds with showInFeed: true
// Create a map to track events by ID for deduplication
const eventsMap = new Map<string, NostrEvent>();
// Function to merge new events into the view
const mergeEvents = (newEvents: NostrEvent[]) => {
for (const event of newEvents) {
eventsMap.set(event.id, event);
}
// Convert map to array, sort by created_at descending (newest first)
const sortedEvents = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at);
events = sortedEvents;
};
// Fetch events from all pubkeys in the list, with showInFeed kinds // Fetch events from all pubkeys in the list, with showInFeed kinds
// useCache: true will return cached events immediately, then fetch from relays in background
// onUpdate will be called as new events arrive from relays
const fetchedEvents = await nostrClient.fetchEvents( const fetchedEvents = await nostrClient.fetchEvents(
[{ [{
kinds: feedKinds, kinds: feedKinds,
@ -150,13 +165,15 @@
limit: 100 limit: 100
}], }],
relays, relays,
{ useCache: true, cacheResults: true } {
useCache: true,
cacheResults: true,
onUpdate: mergeEvents // Update view as new events arrive from relays
}
); );
// Sort by created_at descending (newest first) // Initial merge of cached events
fetchedEvents.sort((a, b) => b.created_at - a.created_at); mergeEvents(fetchedEvents);
events = fetchedEvents;
} catch (error) { } catch (error) {
console.error('Error loading list events:', error); console.error('Error loading list events:', error);
events = []; events = [];

51
src/routes/topics/[name]/+page.svelte

@ -14,6 +14,7 @@
let loading = $state(true); let loading = $state(true);
let topicName = $derived($page.params.name); let topicName = $derived($page.params.name);
let loadingEvents = $state(false); // Guard to prevent concurrent loads let loadingEvents = $state(false); // Guard to prevent concurrent loads
let lastLoadedTopic = $state<string | null>(null); // Track which topic we've loaded
// Pagination: 2 pages of 100 events each (100 per filter from relays, cache can supplement) // Pagination: 2 pages of 100 events each (100 per filter from relays, cache can supplement)
const EVENTS_PER_PAGE = 100; const EVENTS_PER_PAGE = 100;
@ -34,24 +35,24 @@
return Math.ceil(totalEvents / EVENTS_PER_PAGE); return Math.ceil(totalEvents / EVENTS_PER_PAGE);
}); });
// Reset to page 1 when topic changes
$effect(() => {
if ($page.params.name) {
currentPage = 1;
events = []; // Clear events when topic changes
}
});
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
if (topicName) { if (topicName) {
await loadCachedTopicEvents(); await loadCachedTopicEvents();
await loadTopicEvents(); await loadTopicEvents();
lastLoadedTopic = topicName;
} }
}); });
// Only reload when topic name actually changes, not on every reactive update
$effect(() => { $effect(() => {
if ($page.params.name && !loadingEvents) { const currentTopic = $page.params.name;
// Only load if topic changed and we're not already loading
if (currentTopic && currentTopic !== lastLoadedTopic && !loadingEvents) {
// Reset pagination and clear events when topic changes
currentPage = 1;
events = [];
lastLoadedTopic = currentTopic;
loadCachedTopicEvents(); loadCachedTopicEvents();
loadTopicEvents(); loadTopicEvents();
} }
@ -123,33 +124,11 @@
{ useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (t-tag)` } { useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (t-tag)` }
); );
// Also search for hashtags in content, but only fetch a smaller sample // Use t-tag events as the primary source
// We'll only fetch events that might have the hashtag (limited to reduce bandwidth) // Content-based hashtag search is too inefficient (would require fetching all events)
const hashtagPattern = new RegExp(`#${topicName}\\b`, 'i'); // Users should use t-tags for proper topic organization
const allEvents: NostrEvent[] = [...tTagEvents]; const allEvents: NostrEvent[] = [...tTagEvents];
// For content-based hashtag search, fetch a smaller sample and filter
// This is less efficient but catches events without t-tags
// Limit to 50 events to reduce bandwidth usage
const contentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 50 }],
relays,
{ useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (content)` }
);
// Filter events that contain the hashtag in content but don't already have a t-tag
const tTagEventIds = new Set(tTagEvents.map(e => e.id));
for (const event of contentEvents) {
// Only add if it has the hashtag in content and isn't already in tTagEvents
if (!tTagEventIds.has(event.id) && hashtagPattern.test(event.content)) {
// Also check if it doesn't have a t-tag (to avoid duplicates)
const hasTTag = event.tags.some(t => t[0] === 't' && t[1] === topicName);
if (!hasTTag) {
allEvents.push(event);
}
}
}
// Merge with existing cached events and deduplicate // Merge with existing cached events and deduplicate
const eventMap = new Map<string, NostrEvent>(); const eventMap = new Map<string, NostrEvent>();

Loading…
Cancel
Save