Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
794d3fdbcd
  1. 672
      src/lib/components/find/AdvancedSearch.svelte
  2. 277
      src/lib/components/find/NormalSearch.svelte
  3. 3
      src/lib/components/layout/Header.svelte
  4. 87
      src/lib/components/layout/UnifiedSearch.svelte
  5. 69
      src/lib/modules/profiles/PaymentAddresses.svelte
  6. 105
      src/lib/services/cache/event-archive.ts
  7. 50
      src/lib/services/cache/event-cache.ts
  8. 1
      src/lib/types/nostr.ts
  9. 994
      src/routes/bookmarks/+page.svelte
  10. 126
      src/routes/cache/+page.svelte
  11. 1281
      src/routes/find/+page.svelte

672
src/lib/components/find/AdvancedSearch.svelte

@ -0,0 +1,672 @@ @@ -0,0 +1,672 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { nip19 } from 'nostr-tools';
import { KIND_LOOKUP } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) => void;
}
let { onSearchResults }: Props = $props();
// NIP-01 standard filter controls
let filterIds = $state<string>('');
let filterAuthors = $state<string>('');
let filterKinds = $state<string>('');
let filterE = $state<string>('');
let filterP = $state<string>('');
let filterA = $state<string>('');
let filterQ = $state<string>('');
let filterT = $state<string>('');
let filterC = $state<string>('');
let filterD = $state<string>('');
let filterSince = $state<string>('');
let filterUntil = $state<string>('');
let filterLimit = $state<number>(100);
let searching = $state(false);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
const eventRelayMap = new Map<string, string>();
// Helper function to decode bech32 to hex
async function decodeBech32ToHex(bech32: string, type: 'pubkey' | 'event'): Promise<string | null> {
try {
if (!/^(npub|nprofile|note|nevent|naddr)1[a-z0-9]+$/i.test(bech32)) {
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) {
console.debug('Error decoding bech32:', error);
}
return null;
}
// Helper function to parse comma-separated values and validate hex strings
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
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) {
const match = part.match(/^(\d+):([^:]+)(?::(.+))?$/);
if (match) {
const [, kind, pubkeyPart, dTag] = match;
let hexPubkey: string | null = null;
if (/^[0-9a-f]{64}$/i.test(pubkeyPart)) {
hexPubkey = pubkeyPart.toLowerCase();
} else {
hexPubkey = await decodeBech32ToHex(pubkeyPart, 'pubkey');
}
if (hexPubkey) {
if (dTag) {
results.push(`${kind}:${hexPubkey}:${dTag}`);
} else {
results.push(`${kind}:${hexPubkey}:`);
}
}
}
}
return results;
}
// Helper function to convert date-time string to unix timestamp
function dateTimeToUnixTimestamp(dateTimeStr: string): number | null {
if (!dateTimeStr.trim()) return null;
try {
const date = new Date(dateTimeStr.trim());
if (isNaN(date.getTime())) {
const timestamp = parseInt(dateTimeStr.trim(), 10);
if (!isNaN(timestamp) && timestamp > 0) {
return timestamp;
}
return null;
}
return Math.floor(date.getTime() / 1000);
} catch (error) {
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 = {};
if (filterIds.trim()) {
const ids = await parseHexList(filterIds, 'event');
if (ids.length > 0) {
filter.ids = ids;
}
}
if (filterAuthors.trim()) {
const authors = await parseHexList(filterAuthors, 'pubkey');
if (authors.length > 0) {
filter.authors = authors;
}
}
if (filterKinds.trim()) {
const kinds = parseNumberList(filterKinds);
if (kinds.length > 0) {
filter.kinds = kinds;
}
}
if (filterE.trim()) {
const eTags = await parseHexList(filterE, 'event');
if (eTags.length > 0) {
filter['#e'] = eTags;
}
}
if (filterP.trim()) {
const pTags = await parseHexList(filterP, 'pubkey');
if (pTags.length > 0) {
filter['#p'] = pTags;
}
}
if (filterA.trim()) {
const aTags = await parseAddressableList(filterA);
if (aTags.length > 0) {
filter['#a'] = aTags;
}
}
if (filterQ.trim()) {
const qTags = await parseHexList(filterQ, 'event');
if (qTags.length > 0) {
filter['#q'] = qTags;
}
}
if (filterT.trim()) {
const tTags = filterT
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (tTags.length > 0) {
filter['#T'] = tTags;
}
}
if (filterC.trim()) {
const cTags = filterC
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (cTags.length > 0) {
filter['#C'] = cTags;
}
}
if (filterD.trim()) {
const dTags = filterD
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (dTags.length > 0) {
filter['#d'] = dTags;
}
}
if (filterSince.trim()) {
const since = dateTimeToUnixTimestamp(filterSince);
if (since !== null && since > 0) {
filter.since = since;
}
}
if (filterUntil.trim()) {
const until = dateTimeToUnixTimestamp(filterUntil);
if (until !== null && until > 0) {
filter.until = until;
}
}
if (filterLimit > 0) {
filter.limit = filterLimit;
} else {
filter.limit = 100;
}
return filter;
}
async function handleSearch() {
searching = true;
const newEventRelayMap = new Map<string, string>();
try {
await nostrClient.initialize();
const relays = relayManager.getAllAvailableRelays();
const filter = await buildFilter();
// Check if filter has any conditions (not just limit)
const hasConditions = Object.keys(filter).some(key => key !== 'limit');
if (!hasConditions) {
// No filter conditions, show error or return empty
searchResults = { events: [], profiles: [], relays };
if (onSearchResults) {
onSearchResults({ events: [], profiles: [], relays, eventRelays: new Map() });
}
searching = false;
return;
}
// Fetch events using the 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');
}
}
// Sort by created_at (newest first)
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at);
searchResults = { events: sortedEvents, profiles: [], relays };
if (onSearchResults) {
onSearchResults({ events: sortedEvents, profiles: [], relays, eventRelays: newEventRelayMap });
}
} catch (error) {
console.error('Advanced search error:', error);
searchResults = { events: [], profiles: [], relays: [] };
if (onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
} finally {
searching = false;
}
}
export function clearSearch() {
filterIds = '';
filterAuthors = '';
filterKinds = '';
filterE = '';
filterP = '';
filterA = '';
filterQ = '';
filterT = '';
filterC = '';
filterD = '';
filterSince = '';
filterUntil = '';
filterLimit = 100;
searchResults = { events: [], profiles: [] };
eventRelayMap.clear();
if (onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
}
export function getSearchResults() {
return searchResults;
}
</script>
<div class="advanced-search">
<h2>Advanced Search (NIP-01 Filters)</h2>
<p class="section-description">
Use NIP-01 standard filters to search for events. All filters are optional - combine them as needed.
</p>
<div class="search-container">
<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 or bech32 (note, nevent)"
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 or bech32 (npub, nprofile)"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-kinds" class="filter-label">Kinds (comma-separated):</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 or bech32"
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 or bech32"
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 or bech32"
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 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 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>
<div class="search-button-wrapper">
<button
class="search-button"
onclick={handleSearch}
disabled={searching}
aria-label="Search"
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
</div>
<style>
.advanced-search {
/* No container styling - parent .find-section handles it */
}
.advanced-search h2 {
margin: 0 0 1rem 0;
font-size: 1.25em;
font-weight: 600;
color: var(--fog-text, #475569);
}
@media (min-width: 640px) {
.advanced-search h2 {
margin: 0 0 1.5rem 0;
}
}
:global(.dark) .advanced-search h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.section-description {
margin: 0 0 1rem 0;
color: var(--fog-text-light, #52667a);
font-size: 0.875em;
}
@media (min-width: 640px) {
.section-description {
margin: 0 0 1.5rem 0;
}
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.advanced-filters-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0.75rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.375rem;
border: 1px solid var(--fog-border, #e5e7eb);
}
@media (min-width: 640px) {
.advanced-filters-grid {
gap: 1rem;
padding: 1rem;
}
}
: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.875em;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.filter-input {
padding: 0.5em;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875em;
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.75em;
color: var(--fog-text-light, #52667a);
margin-top: 0.25em;
display: block;
}
:global(.dark) .filter-hint {
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-button-wrapper {
display: flex;
justify-content: flex-end;
}
.search-button {
padding: 0.75em 1.5em;
background: var(--fog-accent, #64748b);
color: #ffffff;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875em;
font-weight: 500;
font-family: inherit;
white-space: nowrap;
transition: all 0.2s;
min-width: 100px;
}
:global(.dark) .search-button {
background: var(--fog-dark-accent, #94a3b8);
color: #1f2937;
}
.search-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.search-button:active:not(:disabled) {
transform: translateY(0);
}
.search-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

277
src/lib/components/find/NormalSearch.svelte

@ -0,0 +1,277 @@ @@ -0,0 +1,277 @@
<script lang="ts">
import UnifiedSearch from '../layout/UnifiedSearch.svelte';
import { KIND_LOOKUP } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) => void;
}
let { onSearchResults }: Props = $props();
let unifiedSearchComponent: { triggerSearch: () => void; clearSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null);
let selectedKind = $state<number | null>(null);
let selectedKindString = $state<string>('');
// Get all kinds for dropdown
const allKinds = Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number);
// Sync selectedKindString with selectedKind
$effect(() => {
selectedKindString = selectedKind?.toString() || '';
});
// Sync selectedKind with selectedKindString when it changes
$effect(() => {
if (selectedKindString === '') {
selectedKind = null;
} else {
const parsed = parseInt(selectedKindString);
if (!isNaN(parsed)) {
selectedKind = parsed;
}
}
});
function handleKindChange(e: Event) {
const select = e.target as HTMLSelectElement;
selectedKindString = select.value;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
export function clearSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.clearSearch();
}
selectedKind = null;
selectedKindString = '';
if (onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
}
export function getSearchResults() {
return { events: [], profiles: [] };
}
</script>
<div class="normal-search">
<h2>Normal Search</h2>
<p class="section-description">
Search for events by ID, pubkey, NIP-05, or content. Use the kind filter to narrow results.
</p>
<div class="search-container">
<div class="search-bar-wrapper">
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
selectedKind={selectedKind}
hideDropdownResults={true}
onSearchResults={onSearchResults}
placeholder="Search events, profiles, pubkeys, or enter event ID..."
/>
</div>
<div class="filter-and-button-wrapper">
<div class="kind-filter-wrapper">
<label for="kind-filter" class="kind-filter-label">Filter by Kind:</label>
<select
id="kind-filter"
bind:value={selectedKindString}
onchange={handleKindChange}
class="kind-filter-select"
aria-label="Filter by kind"
>
<option value="">All Kinds</option>
{#each allKinds as kindInfo}
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option>
{/each}
</select>
</div>
<button
class="search-button"
onclick={handleSearch}
disabled={!unifiedSearchComponent || !unifiedSearchComponent.getFilterResult().value?.trim()}
aria-label="Search"
>
Search
</button>
</div>
</div>
</div>
<style>
.normal-search {
/* No container styling - parent .find-section handles it */
}
.normal-search h2 {
margin: 0 0 1rem 0;
font-size: 1.25em;
font-weight: 600;
color: var(--fog-text, #475569);
}
@media (min-width: 640px) {
.normal-search h2 {
margin: 0 0 1.5rem 0;
}
}
:global(.dark) .normal-search h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.section-description {
margin: 0 0 1rem 0;
color: var(--fog-text-light, #52667a);
font-size: 0.875em;
}
@media (min-width: 640px) {
.section-description {
margin: 0 0 1.5rem 0;
}
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-bar-wrapper {
width: 100%;
}
.filter-and-button-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 640px) {
.filter-and-button-wrapper {
flex-direction: row;
align-items: flex-end;
}
}
.kind-filter-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
@media (min-width: 640px) {
.kind-filter-wrapper {
flex-direction: row;
align-items: center;
flex: 1;
}
}
.kind-filter-label {
font-size: 0.875em;
color: var(--fog-text, #1f2937);
font-weight: 500;
white-space: nowrap;
}
:global(.dark) .kind-filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.kind-filter-select {
padding: 0.75em;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875em;
cursor: pointer;
width: 100%;
font-family: inherit;
}
@media (min-width: 640px) {
.kind-filter-select {
width: auto;
min-width: 200px;
}
}
.kind-filter-select:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .kind-filter-select {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .kind-filter-select:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-bar-wrapper :global(.unified-search-container) {
max-width: none;
width: 100%;
}
.search-button {
padding: 0.75em 1.5em;
background: var(--fog-accent, #64748b);
color: #ffffff;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875em;
font-weight: 500;
font-family: inherit;
white-space: nowrap;
transition: all 0.2s;
min-width: 100px;
}
@media (min-width: 640px) {
.search-button {
min-width: auto;
}
}
:global(.dark) .search-button {
background: var(--fog-dark-accent, #94a3b8);
color: #1f2937;
}
.search-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.search-button:active:not(:disabled) {
transform: translateY(0);
}
.search-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

3
src/lib/components/layout/Header.svelte

@ -58,6 +58,9 @@ @@ -58,6 +58,9 @@
{#if isLoggedIn}
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Write</a>
{/if}
{#if isLoggedIn}
<a href="/bookmarks" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Bookmarks</a>
{/if}
<a href="/find" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Find</a>
{#if isLoggedIn}
<a href="/rss" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/RSS</a>

87
src/lib/components/layout/UnifiedSearch.svelte

@ -810,9 +810,11 @@ @@ -810,9 +810,11 @@
}
// 6. Anything else is a full-text search
// Try NIP-50 search first (relay-side full-text search), then fallback to client-side filtering
if (mode === 'search') {
let allEvents: NostrEvent[] = [];
const uniqueEventIds = new Set<string>();
const queryLower = query.toLowerCase();
// Helper to add event and notify if needed
const addEvent = (event: NostrEvent, relay?: string) => {
@ -827,7 +829,6 @@ @@ -827,7 +829,6 @@
// If hideDropdownResults, notify incrementally
if (hideDropdownResults && onSearchResults) {
const queryLower = query.toLowerCase();
const matches = allEvents.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title');
@ -860,11 +861,87 @@ @@ -860,11 +861,87 @@
}
};
const relays = relayManager.getAllAvailableRelays();
// Try NIP-50 search first (relay-side full-text search)
// Split query into words for NIP-50 search
const searchTerms = query.trim().split(/\s+/).filter(term => term.length > 0);
if (searchTerms.length > 0) {
try {
// Build NIP-50 filter with search terms
const nip50Filter: any = {
search: searchTerms,
limit: 100
};
// Add kind filter if specified
if (effectiveKinds && effectiveKinds.length > 0) {
nip50Filter.kinds = effectiveKinds;
}
// Try NIP-50 search on relays
const nip50Events = await nostrClient.fetchEvents(
[nip50Filter],
relays,
{
useCache: 'cache-first',
cacheResults: true,
timeout: 10000,
onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => {
// Add events as they arrive (NIP-50 relays already filtered them)
for (const { event, relay } of eventsWithRelay) {
addEvent(event, relay);
}
}
}
);
// If we got results from NIP-50, use them (relays that support it already filtered)
if (nip50Events.length > 0) {
for (const event of nip50Events) {
addEvent(event, relays[0] || 'unknown');
}
// Sort and return NIP-50 results
const sorted = allEvents.sort((a, b) => {
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;
});
const limitedResults = Array.from(new Map(sorted.map(e => [e.id, e])).values()).slice(0, 100);
if (hideDropdownResults && onSearchResults) {
foundEvents = limitedResults;
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
} else {
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content (NIP-50)' }));
showResults = true;
}
searching = false;
resolving = false;
return;
}
} catch (error) {
// NIP-50 search failed or not supported, fall through to client-side search
console.debug('NIP-50 search not available or failed, falling back to client-side search:', error);
}
}
// Fallback to client-side filtering if NIP-50 didn't return results or isn't supported
// If kinds are specified, search from relays
if (effectiveKinds && effectiveKinds.length > 0) {
const relays = relayManager.getAllAvailableRelays();
const queryLower = query.toLowerCase();
// Search each allowed kind with onUpdate for incremental results
for (const kind of effectiveKinds) {
await nostrClient.fetchEvents(
@ -914,7 +991,6 @@ @@ -914,7 +991,6 @@
}
}
const queryLower = query.toLowerCase();
allEvents = allCached.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
@ -935,7 +1011,6 @@ @@ -935,7 +1011,6 @@
// Final sort and limit (only if not already handled incrementally)
if (!(hideDropdownResults && onSearchResults && effectiveKinds && effectiveKinds.length > 0)) {
const queryLower = query.toLowerCase();
const sorted = allEvents.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower;

69
src/lib/modules/profiles/PaymentAddresses.svelte

@ -26,22 +26,21 @@ @@ -26,22 +26,21 @@
async function loadPaymentAddresses() {
loading = true;
try {
const config = nostrClient.getConfig();
const { getRecentCachedEvents } = await import('../../services/cache/event-cache.js');
const { relayManager } = await import('../../services/nostr/relay-manager.js');
const { config } = await import('../../services/nostr/config.js');
// Try cache first (fast - instant display)
const cachedEvents = await getRecentCachedEvents([KIND.PAYMENT_ADDRESSES], 60 * 60 * 1000, 100); // 1 hour cache
const cachedPaymentEvent = cachedEvents.find(e => e.pubkey === pubkey);
// Fetch kind 10133 (payment targets)
const paymentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.PAYMENT_ADDRESSES], authors: [pubkey], limit: 1 }],
[...config.defaultRelays, ...config.profileRelays],
{ useCache: true, cacheResults: true }
);
const addresses: Array<{ type: string; address: string }> = [];
const seen = new Set<string>();
// Extract from kind 10133
if (paymentEvents.length > 0) {
paymentEvent = paymentEvents[0];
for (const tag of paymentEvent.tags) {
// Extract from cached event if available
if (cachedPaymentEvent) {
paymentEvent = cachedPaymentEvent;
for (const tag of cachedPaymentEvent.tags) {
if (tag[0] === 'payto' && tag[1] && tag[2]) {
const key = `${tag[1]}:${tag[2]}`;
if (!seen.has(key)) {
@ -50,9 +49,11 @@ @@ -50,9 +49,11 @@
}
}
}
paymentAddresses = addresses;
loading = false; // Show cached content immediately
}
// Also get lud16 from profile (kind 0)
// Also get lud16 from profile (kind 0) - this is usually cached
const profile = await fetchProfile(pubkey);
if (profile && profile.lud16) {
for (const lud16 of profile.lud16) {
@ -62,9 +63,49 @@ @@ -62,9 +63,49 @@
seen.add(key);
}
}
paymentAddresses = addresses;
}
paymentAddresses = addresses;
// Fetch from relays in background (progressive enhancement) if not in cache
if (!cachedPaymentEvent) {
const relays = relayManager.getProfileReadRelays();
const paymentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.PAYMENT_ADDRESSES], authors: [pubkey], limit: 1 }],
relays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout }
);
// Update with fresh data if available
if (paymentEvents.length > 0) {
paymentEvent = paymentEvents[0];
const freshAddresses: Array<{ type: string; address: string }> = [];
const freshSeen = new Set<string>();
// Extract from kind 10133
for (const tag of paymentEvents[0].tags) {
if (tag[0] === 'payto' && tag[1] && tag[2]) {
const key = `${tag[1]}:${tag[2]}`;
if (!freshSeen.has(key)) {
freshAddresses.push({ type: tag[1], address: tag[2] });
freshSeen.add(key);
}
}
}
// Merge with lud16 from profile
if (profile && profile.lud16) {
for (const lud16 of profile.lud16) {
const key = `lightning:${lud16}`;
if (!freshSeen.has(key)) {
freshAddresses.push({ type: 'lightning', address: lud16 });
freshSeen.add(key);
}
}
}
paymentAddresses = freshAddresses;
}
}
} catch (error) {
console.error('Error loading payment addresses:', error);
} finally {

105
src/lib/services/cache/event-archive.ts vendored

@ -202,8 +202,10 @@ export async function archiveOldEvents( @@ -202,8 +202,10 @@ export async function archiveOldEvents(
): Promise<number> {
try {
const db = await getDB();
const now = Date.now();
const cutoffTime = now - threshold;
// created_at is in seconds (Unix timestamp), threshold is in milliseconds
// Convert threshold to seconds for comparison
const nowSeconds = Math.floor(Date.now() / 1000);
const cutoffTimeSeconds = nowSeconds - Math.floor(threshold / 1000);
// Find old events
const tx = db.transaction('events', 'readonly');
@ -211,7 +213,7 @@ export async function archiveOldEvents( @@ -211,7 +213,7 @@ export async function archiveOldEvents(
const oldEvents: CachedEvent[] = [];
for await (const cursor of index.iterate()) {
if (cursor.value.created_at < cutoffTime) {
if (cursor.value.created_at < cutoffTimeSeconds) {
oldEvents.push(cursor.value as CachedEvent);
}
}
@ -331,9 +333,13 @@ export async function clearOldArchivedEvents(olderThan: number): Promise<number> @@ -331,9 +333,13 @@ export async function clearOldArchivedEvents(olderThan: number): Promise<number>
const tx = db.transaction('eventArchive', 'readwrite');
const index = tx.store.index('created_at');
// olderThan is in milliseconds, but created_at is in seconds
// Convert to seconds for comparison
const olderThanSeconds = Math.floor(olderThan / 1000);
const idsToDelete: string[] = [];
for await (const cursor of index.iterate()) {
if (cursor.value.created_at < olderThan) {
if (cursor.value.created_at < olderThanSeconds) {
idsToDelete.push(cursor.value.id);
}
}
@ -373,6 +379,7 @@ export async function getArchiveStats(): Promise<{ @@ -373,6 +379,7 @@ export async function getArchiveStats(): Promise<{
for await (const cursor of tx.store.iterate()) {
totalArchived++;
totalSize += cursor.value.compressed.length;
// created_at is in seconds, keep it as-is for consistency
if (!oldestArchived || cursor.value.created_at < oldestArchived) {
oldestArchived = cursor.value.created_at;
}
@ -393,3 +400,93 @@ export async function getArchiveStats(): Promise<{ @@ -393,3 +400,93 @@ export async function getArchiveStats(): Promise<{
};
}
}
/**
* Recover all archived events back to main cache (unarchive all)
*/
export async function recoverAllArchivedEvents(): Promise<number> {
try {
const db = await getDB();
const tx = db.transaction(['events', 'eventArchive'], 'readwrite');
const archivedEvents: CachedEvent[] = [];
// Get all archived events
for await (const cursor of tx.objectStore('eventArchive').iterate()) {
const archived = cursor.value as ArchivedEvent;
// Decompress
const decompressed = await decompressJSON(archived.compressed);
archivedEvents.push(decompressed as CachedEvent);
}
await tx.done;
if (archivedEvents.length === 0) return 0;
// Restore in batches to avoid blocking
const BATCH_SIZE = 50;
let recovered = 0;
for (let i = 0; i < archivedEvents.length; i += BATCH_SIZE) {
const batch = archivedEvents.slice(i, i + BATCH_SIZE);
// Use a new transaction for each batch
const batchTx = db.transaction(['events', 'eventArchive'], 'readwrite');
// Restore events to main cache
await Promise.all(batch.map(event => batchTx.objectStore('events').put(event)));
// Remove from archive
await Promise.all(batch.map(event => batchTx.objectStore('eventArchive').delete(event.id)));
await batchTx.done;
recovered += batch.length;
// Yield to browser between batches to avoid blocking
await new Promise(resolve => setTimeout(resolve, 0));
}
return recovered;
} catch (error) {
// Recovery failed (non-critical)
return 0;
}
}
/**
* Find and recover an archived event by ID
*/
export async function recoverArchivedEventById(id: string): Promise<boolean> {
try {
const archived = await getArchivedEvent(id);
if (!archived) return false;
const db = await getDB();
const tx = db.transaction(['events', 'eventArchive'], 'readwrite');
// Put back in main events store
await tx.objectStore('events').put(archived);
// Remove from archive
await tx.objectStore('eventArchive').delete(id);
await tx.done;
return true;
} catch (error) {
// Recovery failed (non-critical)
return false;
}
}
/**
* Check if an event is archived by ID
*/
export async function isEventArchived(id: string): Promise<boolean> {
try {
const db = await getDB();
const archived = await db.get('eventArchive', id);
return archived !== undefined;
} catch (error) {
return false;
}
}

50
src/lib/services/cache/event-cache.ts vendored

@ -90,7 +90,7 @@ export async function getEvent(id: string): Promise<CachedEvent | undefined> { @@ -90,7 +90,7 @@ export async function getEvent(id: string): Promise<CachedEvent | undefined> {
}
/**
* Get events by kind
* Get events by kind (checks both main cache and archive)
*/
export async function getEventsByKind(kind: number, limit?: number): Promise<CachedEvent[]> {
try {
@ -104,8 +104,28 @@ export async function getEventsByKind(kind: number, limit?: number): Promise<Cac @@ -104,8 +104,28 @@ export async function getEventsByKind(kind: number, limit?: number): Promise<Cac
await tx.done;
// Also check archive for events of this kind
const { getArchivedEventsByKind } = await import('./event-archive.js');
const archivedEvents = await getArchivedEventsByKind(kind, limit);
// Combine and deduplicate (archive might have events that are also in main cache)
const eventMap = new Map<string, CachedEvent>();
// Add main cache events first
for (const event of events) {
eventMap.set(event.id, event as CachedEvent);
}
// Add archived events (won't overwrite if already in main cache)
for (const event of archivedEvents) {
if (!eventMap.has(event.id)) {
eventMap.set(event.id, event);
}
}
// Sort and limit after fetching
const sorted = events.sort((a, b) => b.created_at - a.created_at);
const allEvents = Array.from(eventMap.values());
const sorted = allEvents.sort((a, b) => b.created_at - a.created_at);
return limit ? sorted.slice(0, limit) : sorted;
} catch (error) {
// Cache read failed (non-critical)
@ -114,7 +134,7 @@ export async function getEventsByKind(kind: number, limit?: number): Promise<Cac @@ -114,7 +134,7 @@ export async function getEventsByKind(kind: number, limit?: number): Promise<Cac
}
/**
* Get events by pubkey
* Get events by pubkey (checks both main cache and archive)
*/
export async function getEventsByPubkey(pubkey: string, limit?: number): Promise<CachedEvent[]> {
try {
@ -128,8 +148,28 @@ export async function getEventsByPubkey(pubkey: string, limit?: number): Promise @@ -128,8 +148,28 @@ export async function getEventsByPubkey(pubkey: string, limit?: number): Promise
await tx.done;
// Also check archive for events by this pubkey
const { getArchivedEventsByPubkey } = await import('./event-archive.js');
const archivedEvents = await getArchivedEventsByPubkey(pubkey, limit);
// Combine and deduplicate (archive might have events that are also in main cache)
const eventMap = new Map<string, CachedEvent>();
// Add main cache events first
for (const event of events) {
eventMap.set(event.id, event as CachedEvent);
}
// Add archived events (won't overwrite if already in main cache)
for (const event of archivedEvents) {
if (!eventMap.has(event.id)) {
eventMap.set(event.id, event);
}
}
// Sort and limit after fetching
const sorted = events.sort((a, b) => b.created_at - a.created_at);
const allEvents = Array.from(eventMap.values());
const sorted = allEvents.sort((a, b) => b.created_at - a.created_at);
return limit ? sorted.slice(0, limit) : sorted;
} catch (error) {
// Cache read failed (non-critical)
@ -292,7 +332,7 @@ export async function getCachedReactionsForEvents(eventIds: string[]): Promise<M @@ -292,7 +332,7 @@ export async function getCachedReactionsForEvents(eventIds: string[]): Promise<M
for (const reaction of allReactions) {
// Find the event ID this reaction references (check both 'e' and 'E' tags)
const eventIdTag = reaction.tags.find(t => {
const eventIdTag = reaction.tags.find((t: string[]) => {
const tagName = t[0];
return (tagName === 'e' || tagName === 'E') && t[1] && eventIds.includes(t[1]);
});

1
src/lib/types/nostr.ts

@ -22,4 +22,5 @@ export interface NostrFilter { @@ -22,4 +22,5 @@ export interface NostrFilter {
since?: number;
until?: number;
limit?: number;
search?: string[]; // NIP-50: Full-text search terms
}

994
src/routes/bookmarks/+page.svelte

File diff suppressed because it is too large Load Diff

126
src/routes/cache/+page.svelte vendored

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js';
import { cacheEvent } from '../../lib/services/cache/event-cache.js';
import type { CachedEvent } from '../../lib/services/cache/event-cache.js';
import { getArchiveStats, clearOldArchivedEvents } from '../../lib/services/cache/event-archive.js';
import { getArchiveStats, clearOldArchivedEvents, recoverAllArchivedEvents, recoverArchivedEventById, isEventArchived } from '../../lib/services/cache/event-archive.js';
import { triggerArchive } from '../../lib/services/cache/archive-scheduler.js';
import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools';
@ -42,6 +42,9 @@ @@ -42,6 +42,9 @@
let offset = $state(0);
const PAGE_SIZE = 50;
let archiving = $state(false);
let recovering = $state(false);
let eventIdToRecover = $state<string>('');
let recoveringEvent = $state(false);
// Filters
let selectedKind = $state<number | null>(null);
@ -107,6 +110,64 @@ @@ -107,6 +110,64 @@
}
}
async function handleRecoverAllArchived() {
if (!confirm('Are you sure you want to recover all archived events? This will restore them to the main cache.')) {
return;
}
recovering = true;
try {
const recovered = await recoverAllArchivedEvents();
await loadStats();
await loadArchiveStats();
await loadEvents(true);
alert(`Recovered ${recovered} archived events. They are now back in the main cache.`);
} catch (error) {
alert('Failed to recover archived events');
} finally {
recovering = false;
}
}
async function handleRecoverEventById() {
if (!eventIdToRecover.trim()) {
alert('Please enter an event ID');
return;
}
// Decode bech32 if needed
const hexId = decodeEventIdToHex(eventIdToRecover.trim());
if (!hexId) {
alert('Invalid event ID format');
return;
}
// Check if event is archived
const isArchived = await isEventArchived(hexId);
if (!isArchived) {
alert('Event not found in archive. It may already be in the main cache or may not exist.');
return;
}
recoveringEvent = true;
try {
const recovered = await recoverArchivedEventById(hexId);
if (recovered) {
await loadStats();
await loadArchiveStats();
await loadEvents(true);
alert('Event recovered successfully. It is now back in the main cache.');
eventIdToRecover = '';
} else {
alert('Failed to recover event');
}
} catch (error) {
alert('Failed to recover event');
} finally {
recoveringEvent = false;
}
}
async function loadEvents(reset = false) {
if (reset) {
offset = 0;
@ -621,7 +682,7 @@ @@ -621,7 +682,7 @@
</p>
{#if archiveStats.oldestArchived}
<p>
<strong>Oldest Archived:</strong> {new Date(archiveStats.oldestArchived).toLocaleDateString()}
<strong>Oldest Archived:</strong> {new Date(archiveStats.oldestArchived * 1000).toLocaleDateString()}
</p>
{/if}
</div>
@ -633,6 +694,13 @@ @@ -633,6 +694,13 @@
>
{archiving ? 'Archiving...' : 'Archive Events Older Than 30 Days'}
</button>
<button
class="bulk-action-button"
onclick={handleRecoverAllArchived}
disabled={recovering}
>
{recovering ? 'Recovering...' : 'Recover All Archived Events'}
</button>
<button
class="bulk-action-button"
onclick={() => handleClearOldArchived(365)}
@ -640,6 +708,28 @@ @@ -640,6 +708,28 @@
Clear Archived Events Older Than 1 Year
</button>
</div>
<div class="recover-event-section">
<h3>Recover Individual Event</h3>
<div class="recover-event-input-group">
<input
type="text"
bind:value={eventIdToRecover}
placeholder="Enter event ID (hex, nevent, note)..."
class="filter-input"
onkeydown={(e) => e.key === 'Enter' && handleRecoverEventById()}
/>
<button
class="bulk-action-button"
onclick={handleRecoverEventById}
disabled={recoveringEvent || !eventIdToRecover.trim()}
>
{recoveringEvent ? 'Recovering...' : 'Recover Event'}
</button>
</div>
<p class="archive-note">
Enter an event ID to check if it's archived and recover it to the main cache.
</p>
</div>
<p class="archive-note">
Archived events are compressed to save space but remain accessible.
They are automatically decompressed when needed.
@ -873,6 +963,38 @@ @@ -873,6 +963,38 @@
color: var(--fog-dark-text-light, #a8b8d0);
}
.recover-event-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .recover-event-section {
border-top-color: var(--fog-dark-border, #475569);
}
.recover-event-section h3 {
margin: 0 0 1rem 0;
font-size: 1.1em;
color: var(--fog-text, #1f2937);
}
:global(.dark) .recover-event-section h3 {
color: var(--fog-dark-text, #f9fafb);
}
.recover-event-input-group {
display: flex;
gap: 0.75rem;
align-items: flex-start;
flex-wrap: wrap;
}
.recover-event-input-group .filter-input {
flex: 1;
min-width: 250px;
}
.bulk-actions-section,
.events-section {
margin-bottom: 2rem;

1281
src/routes/find/+page.svelte

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save