diff --git a/app/web/src/App.svelte b/app/web/src/App.svelte index b1d031a..b54fe8c 100644 --- a/app/web/src/App.svelte +++ b/app/web/src/App.svelte @@ -10,12 +10,16 @@ import RecoveryView from "./RecoveryView.svelte"; import SprocketView from "./SprocketView.svelte"; import SearchResultsView from "./SearchResultsView.svelte"; + import FilterBuilder from "./FilterBuilder.svelte"; + import FilterDisplay from "./FilterDisplay.svelte"; + import { buildFilter } from "./helpers.tsx"; import { initializeNostrClient, fetchUserProfile, fetchAllEvents, fetchUserEvents, searchEvents, + fetchEvents, fetchEventById, fetchDeleteEventsByTarget, queryEvents, @@ -43,7 +47,7 @@ let userSigner = null; let showSettingsDrawer = false; let selectedTab = localStorage.getItem("selectedTab") || "export"; - let isSearchMode = false; + let showFilterBuilder = false; // Show advanced filter builder let searchQuery = ""; let searchTabs = []; let allEvents = []; @@ -58,7 +62,7 @@ let viewAsRole = ""; // Search results state - let searchResults = new Map(); // Map of searchTabId -> { events, isLoading, hasMore, oldestTimestamp } + let searchResults = new Map(); // Map of searchTabId -> { filter, events, isLoading, hasMore, oldestTimestamp } let isLoadingSearch = false; // Screen-filling events view state @@ -1671,37 +1675,43 @@ } function toggleSearchMode() { - isSearchMode = !isSearchMode; - if (!isSearchMode) { + showFilterBuilder = !showFilterBuilder; + if (!showFilterBuilder) { searchQuery = ""; } } function handleSearchKeydown(event) { if (event.key === "Enter" && searchQuery.trim()) { - createSearchTab(searchQuery.trim()); + createSimpleSearchTab(searchQuery.trim()); searchQuery = ""; - isSearchMode = false; + showFilterBuilder = false; } else if (event.key === "Escape") { - isSearchMode = false; + showFilterBuilder = false; searchQuery = ""; } } - function createSearchTab(query) { + function createSimpleSearchTab(query) { + const filter = buildFilter({ searchText: query, limit: 100 }); + createSearchTab(filter, `Search: ${query}`); + } + + function createSearchTab(filter, label) { const searchTabId = `search-${Date.now()}`; const newSearchTab = { id: searchTabId, icon: "๐Ÿ”", - label: query, + label: label, isSearchTab: true, - query: query, + filter: filter, }; searchTabs = [...searchTabs, newSearchTab]; selectedTab = searchTabId; // Initialize search results for this tab searchResults.set(searchTabId, { + filter: filter, events: [], isLoading: false, hasMore: true, @@ -1709,7 +1719,44 @@ }); // Start loading search results - loadSearchResults(searchTabId, query); + loadSearchResults(searchTabId, true); + } + + function handleFilterApply(event) { + const { searchText, selectedKinds, pubkeys, eventIds, tags, sinceTimestamp, untilTimestamp, limit } = event.detail; + + const filter = buildFilter({ + searchText, + kinds: selectedKinds, + authors: pubkeys, + ids: eventIds, + tags, + since: sinceTimestamp, + until: untilTimestamp, + limit: limit || 100, + }); + + let label = "Filter"; + if (searchText) { + label = `Search: ${searchText.substring(0, 20)}${searchText.length > 20 ? '...' : ''}`; + } else if (selectedKinds.length > 0) { + label = `Kinds: ${selectedKinds.slice(0, 3).join(', ')}${selectedKinds.length > 3 ? '...' : ''}`; + } else if (pubkeys.length > 0) { + label = `Authors: ${pubkeys.length}`; + } + + createSearchTab(filter, label); + showFilterBuilder = false; + } + + function handleFilterClear() { + // Just close the filter builder + showFilterBuilder = false; + } + + function handleFilterSweep(searchTabId) { + // Close the search tab + closeSearchTab(searchTabId); } function closeSearchTab(tabId) { @@ -1720,7 +1767,7 @@ } } - async function loadSearchResults(searchTabId, query, reset = true) { + async function loadSearchResults(searchTabId, reset = true) { const searchResult = searchResults.get(searchTabId); if (!searchResult || searchResult.isLoading) return; @@ -1729,20 +1776,25 @@ searchResults.set(searchTabId, searchResult); try { - const options = { - limit: reset ? 100 : 200, - until: reset - ? Math.floor(Date.now() / 1000) - : searchResult.oldestTimestamp, - }; + const filter = { ...searchResult.filter }; + + // Apply timestamp-based pagination + if (!reset && searchResult.oldestTimestamp) { + filter.until = searchResult.oldestTimestamp; + } + + // Override limit for pagination + if (!reset) { + filter.limit = 200; + } console.log( - "Loading search results for query:", - query, - "with options:", - options, + "Loading search results with filter:", + filter, ); - const events = await searchEvents(query, options); + + // Use fetchEvents with the filter array + const events = await fetchEvents([filter], { timeout: 30000 }); console.log("Received search results:", events.length, "events"); if (reset) { @@ -1768,7 +1820,7 @@ } } - searchResult.hasMore = events.length === (reset ? 100 : 200); + searchResult.hasMore = events.length === (reset ? filter.limit || 100 : 200); searchResult.isLoading = false; searchResults.set(searchTabId, searchResult); } catch (error) { @@ -1780,10 +1832,7 @@ } async function loadMoreSearchResults(searchTabId) { - const searchTab = searchTabs.find((tab) => tab.id === searchTabId); - if (searchTab) { - await loadSearchResults(searchTabId, searchTab.query, false); - } + await loadSearchResults(searchTabId, false); } function handleSearchScroll(event, searchTabId) { @@ -2485,7 +2534,7 @@
+ +{#if showFilterBuilder} +
+
+ +
+
+{/if} +
@@ -2750,13 +2811,12 @@ {#if searchTab.id === selectedTab}
-

๐Ÿ” Search Results: "{searchTab.query}"

+

๐Ÿ” {searchTab.label}

+ + + handleFilterSweep(searchTab.id)} + /> +
@@ -2866,7 +2933,7 @@ {:else if !searchResults.get(searchTab.id)?.isLoading}

- No search results found for "{searchTab.query}". + No search results found.

{/if} @@ -4067,4 +4134,42 @@ background: var(--header-bg); border: none; } + + /* Filter Builder Overlay */ + .filter-builder-overlay { + position: fixed; + top: 3.5em; /* Below the header */ + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: flex; + justify-content: center; + align-items: flex-start; + overflow-y: auto; + padding: 1em; + } + + .filter-builder-container { + width: 100%; + max-width: 900px; + background: var(--bg-color); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + margin-top: 2em; + max-height: calc(100vh - 7em); + overflow-y: auto; + } + + @media (max-width: 768px) { + .filter-builder-overlay { + top: 3em; + } + + .filter-builder-container { + margin-top: 0; + max-height: calc(100vh - 5em); + } + } diff --git a/app/web/src/FilterBuilder.svelte b/app/web/src/FilterBuilder.svelte new file mode 100644 index 0000000..fa811f0 --- /dev/null +++ b/app/web/src/FilterBuilder.svelte @@ -0,0 +1,689 @@ + + +
+ +
+ + +
+ + +
+ + + + {#if showKindsPicker} +
+ +
+ {#each filteredKinds as { kind, name }} + + {/each} +
+
+ {/if} + + + {#if selectedKinds.length > 0} +
+ {#each selectedKinds as kind} +
+ {kind}: {KIND_NAMES[kind] || `Kind ${kind}`} + +
+ {/each} +
+ {/if} +
+ + +
+ +
+ e.key === 'Enter' && addPubkey()} + /> + +
+ {#if pubkeyError} +
{pubkeyError}
+ {/if} + {#if pubkeys.length > 0} +
+ {#each pubkeys as pubkey} +
+ {pubkey} + +
+ {/each} +
+ {/if} +
+ + +
+ +
+ e.key === 'Enter' && addEventId()} + /> + +
+ {#if eventIdError} +
{eventIdError}
+ {/if} + {#if eventIds.length > 0} +
+ {#each eventIds as eventId} +
+ {eventId} + +
+ {/each} +
+ {/if} +
+ + +
+ +
+ # + + e.key === 'Enter' && addTag()} + /> + +
+ {#if tagNameError} +
{tagNameError}
+ {/if} + {#if tags.length > 0} +
+ {#each tags as tag, index} +
+ #{tag.name}: {tag.value} + +
+ {/each} +
+ {/if} +
+ + +
+
+ + + {#if sinceTimestamp} + + {/if} +
+ +
+ + + {#if untilTimestamp} + + {/if} +
+
+ + +
+ + +
+ + +
+ + +
+
+ + + diff --git a/app/web/src/FilterDisplay.svelte b/app/web/src/FilterDisplay.svelte new file mode 100644 index 0000000..6cb3910 --- /dev/null +++ b/app/web/src/FilterDisplay.svelte @@ -0,0 +1,115 @@ + + +{#if showFilter && hasFilter} +
+
+

Active Filter

+ +
+
+
{filterJson}
+
+
+{/if} + + + diff --git a/app/web/src/constants.js b/app/web/src/constants.js index dbbd60e..e4b6c9a 100644 --- a/app/web/src/constants.js +++ b/app/web/src/constants.js @@ -1,5 +1,6 @@ // Default Nostr relays for searching export const DEFAULT_RELAYS = [ // Use the local relay WebSocket endpoint - `wss://${window.location.host}/`, + // Automatically use ws:// for http:// and wss:// for https:// + `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/`, ]; diff --git a/app/web/src/helpers.tsx b/app/web/src/helpers.tsx new file mode 100644 index 0000000..eb57cf5 --- /dev/null +++ b/app/web/src/helpers.tsx @@ -0,0 +1,203 @@ +// Helper functions and constants for the ORLY dashboard + +// Comprehensive kind names mapping +export const KIND_NAMES = { + 0: "Profile Metadata", + 1: "Text Note", + 2: "Recommend Relay", + 3: "Contacts", + 4: "Encrypted DM", + 5: "Delete Request", + 6: "Repost", + 7: "Reaction", + 8: "Badge Award", + 16: "Generic Repost", + 40: "Channel Creation", + 41: "Channel Metadata", + 42: "Channel Message", + 43: "Channel Hide Message", + 44: "Channel Mute User", + 1063: "File Metadata", + 1311: "Live Chat Message", + 1984: "Reporting", + 1985: "Label", + 9734: "Zap Request", + 9735: "Zap Receipt", + 10000: "Mute List", + 10001: "Pin List", + 10002: "Relay List Metadata", + 10003: "Bookmark List", + 10004: "Communities List", + 10005: "Public Chats List", + 10006: "Blocked Relays List", + 10007: "Search Relays List", + 10009: "User Groups", + 10015: "Interests List", + 10030: "User Emoji List", + 13194: "Wallet Info", + 22242: "Client Auth", + 23194: "Wallet Request", + 23195: "Wallet Response", + 24133: "Nostr Connect", + 27235: "HTTP Auth", + 30000: "Categorized People List", + 30001: "Categorized Bookmarks", + 30002: "Categorized Relay List", + 30003: "Bookmark Sets", + 30004: "Curation Sets", + 30005: "Video Sets", + 30008: "Profile Badges", + 30009: "Badge Definition", + 30015: "Interest Sets", + 30017: "Create/Update Stall", + 30018: "Create/Update Product", + 30019: "Marketplace UI/UX", + 30020: "Product Sold As Auction", + 30023: "Long-form Content", + 30024: "Draft Long-form Content", + 30030: "Emoji Sets", + 30063: "Release Artifact Sets", + 30078: "Application-specific Data", + 30311: "Live Event", + 30315: "User Statuses", + 30388: "Slide Set", + 30402: "Classified Listing", + 30403: "Draft Classified Listing", + 30617: "Repository Announcement", + 30618: "Repository State Announcement", + 30818: "Wiki Article", + 30819: "Redirects", + 31922: "Date-Based Calendar Event", + 31923: "Time-Based Calendar Event", + 31924: "Calendar", + 31925: "Calendar Event RSVP", + 31989: "Handler Recommendation", + 31990: "Handler Information", + 34550: "Community Definition", + 34551: "Community Post Approval", +}; + +// Get human-readable kind name +export function getKindName(kind) { + return KIND_NAMES[kind] || `Kind ${kind}`; +} + +// Validate hex string (for pubkeys and event IDs) +export function isValidHex(str, length = null) { + if (!str || typeof str !== "string") return false; + const hexRegex = /^[0-9a-fA-F]+$/; + if (!hexRegex.test(str)) return false; + if (length && str.length !== length) return false; + return true; +} + +// Validate pubkey (64 character hex) +export function isValidPubkey(pubkey) { + return isValidHex(pubkey, 64); +} + +// Validate event ID (64 character hex) +export function isValidEventId(eventId) { + return isValidHex(eventId, 64); +} + +// Validate tag name (single letter a-zA-Z) +export function isValidTagName(tagName) { + return /^[a-zA-Z]$/.test(tagName); +} + +// Format timestamp to localized string +export function formatTimestamp(timestamp) { + return new Date(timestamp * 1000).toLocaleString(); +} + +// Format timestamp for datetime-local input +export function formatDateTimeLocal(timestamp) { + const date = new Date(timestamp * 1000); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +// Parse datetime-local input to unix timestamp +export function parseDateTimeLocal(dateTimeString) { + return Math.floor(new Date(dateTimeString).getTime() / 1000); +} + +// Truncate pubkey for display +export function truncatePubkey(pubkey) { + if (!pubkey) return ""; + return pubkey.slice(0, 8) + "..." + pubkey.slice(-8); +} + +// Truncate content for display +export function truncateContent(content, maxLength = 100) { + if (!content) return ""; + return content.length > maxLength ? content.slice(0, maxLength) + "..." : content; +} + +// Build Nostr filter from form data +export function buildFilter({ + searchText = null, + kinds = [], + authors = [], + ids = [], + tags = [], + since = null, + until = null, + limit = null, +}) { + const filter = {}; + + if (searchText && searchText.trim()) { + filter.search = searchText.trim(); + } + + if (kinds && kinds.length > 0) { + filter.kinds = kinds; + } + + if (authors && authors.length > 0) { + filter.authors = authors; + } + + if (ids && ids.length > 0) { + filter.ids = ids; + } + + // Add tag filters (e.g., #e, #p, #a) + if (tags && tags.length > 0) { + tags.forEach(tag => { + if (tag.name && tag.value) { + const tagKey = `#${tag.name}`; + if (!filter[tagKey]) { + filter[tagKey] = []; + } + filter[tagKey].push(tag.value); + } + }); + } + + if (since) { + filter.since = since; + } + + if (until) { + filter.until = until; + } + + if (limit && limit > 0) { + filter.limit = limit; + } + + return filter; +} + +// Pretty print JSON with word breaking for long strings +export function prettyPrintFilter(filter) { + return JSON.stringify(filter, null, 2); +} + diff --git a/pkg/version/version b/pkg/version/version index 9ca2c5e..f7689f3 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.25.7 \ No newline at end of file +v0.26.0 \ No newline at end of file