Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
85890f4f88
  1. 4
      public/healthz.json
  2. 701
      src/lib/components/content/GifPicker.svelte
  3. 132
      src/lib/services/cache/cache-manager.ts
  4. 26
      src/lib/services/nostr/auth-handler.ts
  5. 268
      src/lib/services/nostr/event-hierarchy.ts
  6. 75
      src/lib/services/nostr/gif-service.ts
  7. 56
      src/lib/services/nostr/nostr-client.ts
  8. 3
      src/lib/types/kind-lookup.ts
  9. 504
      src/routes/cache/+page.svelte
  10. 3
      src/routes/rss/[pubkey]/+server.ts

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-04T11:12:13.164Z",
"buildTime": "2026-02-04T11:59:22.072Z",
"gitCommit": "unknown",
"timestamp": 1770203533165
"timestamp": 1770206362072
}

701
src/lib/components/content/GifPicker.svelte

@ -1,11 +1,12 @@ @@ -1,11 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchGifs, searchGifs, type GifMetadata } from '../../services/nostr/gif-service.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { signAndPublish, signHttpAuth } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
interface Props {
open: boolean;
@ -25,22 +26,36 @@ @@ -25,22 +26,36 @@
let uploadError: string | null = $state(null);
let fileInput: HTMLInputElement | null = $state(null);
// Metadata form state
let showMetadataForm = $state(false);
let pendingUpload: { file: File; fileUrl: string } | null = $state(null);
let metadataForm = $state({
title: '',
summary: '',
alt: '',
dim: '',
blurhash: '',
thumb: '',
image: '',
content: ''
});
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
// Debounce search
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
async function loadGifs(query?: string) {
async function loadGifs(query?: string, forceRefresh: boolean = false) {
loading = true;
error = null;
try {
console.log('[GifPicker] Loading GIFs, query:', query || 'none');
console.log('[GifPicker] Loading GIFs, query:', query || 'none', forceRefresh ? '(force refresh)' : '');
let results: GifMetadata[];
if (query && query.trim()) {
results = await searchGifs(query.trim(), 50);
results = await searchGifs(query.trim(), 50, forceRefresh);
} else {
results = await fetchGifs(undefined, 50);
results = await fetchGifs(undefined, 50, forceRefresh);
}
console.log('[GifPicker] Loaded', results.length, 'GIFs');
gifs = results;
@ -102,7 +117,7 @@ @@ -102,7 +117,7 @@
}
}
// Convert file to data URL
// Convert file to data URL (fallback method)
function fileToDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -112,6 +127,135 @@ @@ -112,6 +127,135 @@
});
}
// Upload file to media server using NIP-96 discovery (like jumble)
async function uploadFileToServer(file: File): Promise<string> {
// Get media upload server from preferences
// Note: nostr.build has CORS issues, so users may need to configure an alternative
const mediaServer = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
// For very large files, warn but still try
const maxRecommendedSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxRecommendedSize) {
console.warn(`[GifPicker] File ${file.name} is large (${(file.size / 1024 / 1024).toFixed(2)}MB), upload may fail`);
}
// Step 1: Try NIP-96 discovery (like jumble does)
let uploadUrl: string | null = null;
try {
const nip96Url = `${mediaServer}/.well-known/nostr/nip96.json`;
console.log(`[GifPicker] Trying NIP-96 discovery: ${nip96Url}`);
const discoveryResponse = await fetch(nip96Url);
if (discoveryResponse.ok) {
const discoveryData = await discoveryResponse.json();
uploadUrl = discoveryData?.api_url;
if (uploadUrl) {
console.log(`[GifPicker] Found NIP-96 upload URL: ${uploadUrl}`);
}
}
} catch (error) {
console.log(`[GifPicker] NIP-96 discovery failed, will try direct endpoints:`, error);
}
// Step 2: If no NIP-96 URL, try common endpoints
const endpoints = uploadUrl
? [uploadUrl]
: [
`${mediaServer}/api/upload`,
`${mediaServer}/upload`,
`${mediaServer}/api/v1/upload`,
`${mediaServer}/api/v2/upload`,
mediaServer // Direct to root
];
// Step 3: Try uploading using XMLHttpRequest (like jumble) for better error handling
const formData = new FormData();
formData.append('file', file);
let lastError: Error | null = null;
for (const endpoint of endpoints) {
try {
console.log(`[GifPicker] Trying upload endpoint: ${endpoint}`);
// Sign HTTP auth BEFORE creating the Promise (like jumble does)
let authHeader: string | null = null;
try {
const session = sessionManager.getSession();
if (session) {
authHeader = await signHttpAuth(endpoint, 'POST', 'Uploading media file');
}
} catch (authError) {
console.warn('[GifPicker] Failed to sign HTTP auth, trying without:', authError);
// Continue without auth - some servers might not require it
}
// Use XMLHttpRequest for better error handling (like jumble)
// Match jumble's exact implementation
const result = await new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint);
xhr.responseType = 'json';
// Add HTTP auth header if we have it (exactly like jumble)
if (authHeader) {
xhr.setRequestHeader('Authorization', authHeader);
}
// Don't set any other headers - let browser handle Content-Type for FormData
xhr.onerror = () => {
// Network errors - match jumble's exact error message
reject(new Error('Network error'));
};
xhr.onload = () => {
// Match jumble's exact error handling
if (xhr.status >= 200 && xhr.status < 300) {
const data = xhr.response;
try {
// Match jumble's exact parsing logic
const tags = data?.nip94_event?.tags ?? [];
const urlTag = tags.find((tag: string[]) => tag[0] === 'url');
if (urlTag?.[1]) {
console.log(`[GifPicker] Upload successful (NIP-96), got URL: ${urlTag[1]}`);
resolve(urlTag[1]);
return;
} else {
reject(new Error('No url found'));
return;
}
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
} else {
// Match jumble's exact error format
reject(new Error(xhr.status.toString() + ' ' + xhr.statusText));
}
};
xhr.send(formData);
});
return result;
} catch (error) {
// NetworkError, CORS error, etc.
const errorMsg = error instanceof Error ? error.message : String(error);
lastError = error instanceof Error ? error : new Error(errorMsg);
console.warn(`[GifPicker] Endpoint ${endpoint} failed:`, errorMsg);
// If it's a CORS or network error on the first endpoint, don't try others
if (endpoint === endpoints[0] && (errorMsg.includes('NetworkError') || errorMsg.includes('CORS') || errorMsg.includes('Failed to fetch'))) {
console.warn(`[GifPicker] Network/CORS error on primary endpoint, skipping remaining endpoints`);
break;
}
continue;
}
}
// All endpoints failed - throw error so caller can handle fallback
throw lastError || new Error('Failed to upload file: all endpoints failed');
}
// Handle file upload
async function handleFileUpload(e: Event) {
const target = e.target as HTMLInputElement;
@ -126,6 +270,8 @@ @@ -126,6 +270,8 @@
uploading = true;
uploadError = null;
let successCount = 0;
let errorCount = 0;
try {
const relays = relayManager.getPublishRelays(
@ -135,41 +281,109 @@ @@ -135,41 +281,109 @@
// Process each selected file
for (const file of Array.from(files)) {
// Verify it's a GIF
if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) {
uploadError = `${file.name} is not a GIF file`;
continue;
try {
// Verify it's a GIF
if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) {
uploadError = `${file.name} is not a GIF file`;
errorCount++;
continue;
}
// Check file size (warn if too large, but try anyway)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
console.warn(`[GifPicker] File ${file.name} is large (${(file.size / 1024 / 1024).toFixed(2)}MB), upload may take a while`);
}
// Upload file to media server to get URL
// We always try to upload to server first - data URLs are too large for relays
let fileUrl: string;
try {
fileUrl = await uploadFileToServer(file);
console.log(`[GifPicker] Uploaded ${file.name} to ${fileUrl}`);
} catch (uploadError) {
console.error(`[GifPicker] Failed to upload ${file.name} to media server:`, uploadError);
const errorMessage = uploadError instanceof Error ? uploadError.message : String(uploadError);
// Only use data URL fallback for very small files (< 500KB) as last resort
// Data URLs are ~33% larger and can cause "Txn is too big" errors on relays
const maxDataUrlSize = 500 * 1024; // 500KB - very conservative limit
if (file.size <= maxDataUrlSize) {
console.warn(`[GifPicker] Using data URL fallback for ${file.name} (${(file.size / 1024).toFixed(1)}KB) - this is not recommended`);
fileUrl = await fileToDataURL(file);
console.log(`[GifPicker] Created data URL for ${file.name} (${(fileUrl.length / 1024).toFixed(1)}KB)`);
} else {
// File is too large for data URL fallback
const serverUrl = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
throw new Error(
`Failed to upload ${file.name} to media server: ${errorMessage}. ` +
`File is too large (${(file.size / 1024 / 1024).toFixed(2)}MB) to use data URL fallback. ` +
`If you're seeing CORS errors, try configuring a different media upload server in Preferences (current: ${serverUrl}). ` +
`Some servers that support CORS: https://void.cat, https://nostrimg.com, or your own NIP-96 server.`
);
}
}
// Create kind 1063 event (NIP-94 file metadata)
// Use proper NIP-94 format: ["file", "<URL>", "<mime-type>", "size <bytes>", ...]
const event: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.FILE_METADATA,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['file', fileUrl, file.type || 'image/gif', `size ${file.size}`]
],
content: ''
};
// Store pending upload for metadata form
pendingUpload = { file, fileUrl };
showMetadataForm = true;
// Reset metadata form
metadataForm = {
title: '',
summary: '',
alt: '',
dim: '',
blurhash: '',
thumb: '',
image: '',
content: ''
};
} catch (error) {
errorCount++;
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[GifPicker] Error processing ${file.name}:`, error);
uploadError = uploadError
? `${uploadError}\n${file.name}: ${errorMessage}`
: `${file.name}: ${errorMessage}`;
}
// Convert to data URL
const dataUrl = await fileToDataURL(file);
// Create kind 1063 event (NIP-94 file metadata)
const event: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.FILE_METADATA,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['url', dataUrl],
['m', file.type || 'image/gif'],
['size', file.size.toString()]
],
content: ''
};
// Publish the event
await signAndPublish(event, relays);
}
// Reload GIFs to show the newly uploaded ones
await loadGifs(searchQuery);
// Show success message
if (successCount > 0) {
const message = `Successfully uploaded ${successCount} GIF${successCount > 1 ? 's' : ''}${errorCount > 0 ? ` (${errorCount} failed)` : ''}`;
console.log(`[GifPicker] ${message}`);
// Clear error if we had some successes
if (errorCount === 0) {
uploadError = null;
}
// Reload GIFs to show the newly uploaded ones
// The event is already cached, so we can reload from cache immediately
// No need to force refresh since the event is in cache
await loadGifs(searchQuery, false);
}
// Reset file input
if (fileInput) {
fileInput.value = '';
}
} catch (error) {
console.error('Error uploading GIF:', error);
console.error('[GifPicker] Error uploading GIF:', error);
uploadError = error instanceof Error ? error.message : 'Failed to upload GIF';
} finally {
uploading = false;
@ -179,6 +393,124 @@ @@ -179,6 +393,124 @@
function triggerFileUpload() {
fileInput?.click();
}
async function publishWithMetadata() {
if (!pendingUpload) return;
const { file, fileUrl } = pendingUpload;
const session = sessionManager.getSession();
if (!session) {
uploadError = 'Please log in to publish GIFs';
return;
}
uploading = true;
uploadError = null;
try {
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
// Build tags array with metadata
const tags: string[][] = [
['file', fileUrl, file.type || 'image/gif', `size ${file.size}`]
];
// Add URL tag (alternative to file tag)
tags.push(['url', fileUrl]);
// Add mime type tag
tags.push(['m', file.type || 'image/gif']);
// Add dimensions if provided
if (metadataForm.dim) {
tags.push(['dim', metadataForm.dim]);
}
// Add blurhash if provided
if (metadataForm.blurhash) {
tags.push(['blurhash', metadataForm.blurhash]);
tags.push(['bh', metadataForm.blurhash]); // Alternative tag name
}
// Add thumbnail if provided
if (metadataForm.thumb) {
tags.push(['thumb', metadataForm.thumb]);
}
// Add image if provided
if (metadataForm.image) {
tags.push(['image', metadataForm.image]);
}
// Add title/tags if provided
if (metadataForm.title) {
tags.push(['t', metadataForm.title]);
}
// Add summary if provided
if (metadataForm.summary) {
tags.push(['summary', metadataForm.summary]);
}
// Add alt text if provided
if (metadataForm.alt) {
tags.push(['alt', metadataForm.alt]);
}
// Build content from summary and alt
const contentParts: string[] = [];
if (metadataForm.summary) contentParts.push(metadataForm.summary);
if (metadataForm.alt) contentParts.push(metadataForm.alt);
const content = contentParts.join(' ');
// Create kind 1063 event with metadata
const event: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.FILE_METADATA,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags,
content
};
// Sign the event
const signedEvent = await sessionManager.signEvent(event);
// Cache the event immediately
await cacheEvent(signedEvent);
// Publish the event
const result = await signAndPublish(event, relays);
if (result.success.length > 0) {
console.log(`[GifPicker] Published file metadata event for ${file.name} to ${result.success.length} relay(s)`);
showMetadataForm = false;
pendingUpload = null;
// Reload GIFs
await loadGifs(searchQuery, false);
} else {
const errorMsg = result.failed.length > 0
? result.failed.map(f => `${f.relay}: ${f.error}`).join(', ')
: 'No relays accepted the event';
throw new Error(`Failed to publish ${file.name}: ${errorMsg}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[GifPicker] Error publishing ${file.name}:`, error);
uploadError = errorMessage;
} finally {
uploading = false;
}
}
function cancelMetadataForm() {
showMetadataForm = false;
pendingUpload = null;
uploading = false;
}
</script>
{#if open}
@ -295,6 +627,116 @@ @@ -295,6 +627,116 @@
</div>
{/if}
<!-- Metadata Form Modal -->
{#if showMetadataForm && pendingUpload}
<div
class="metadata-modal-backdrop"
onclick={cancelMetadataForm}
onkeydown={(e) => e.key === 'Escape' && cancelMetadataForm()}
role="button"
tabindex="0"
aria-label="Close metadata form"
>
<div
class="metadata-modal"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="metadata-modal-title"
tabindex="-1"
>
<div class="metadata-modal-header">
<h3 id="metadata-modal-title">Add Metadata for {pendingUpload.file.name}</h3>
<button class="metadata-modal-close" onclick={cancelMetadataForm}>×</button>
</div>
<div class="metadata-modal-content">
<div class="metadata-form-group">
<label for="metadata-title">Title/Tags (t tag)</label>
<input
id="metadata-title"
type="text"
bind:value={metadataForm.title}
placeholder="e.g., gifbuddy"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="metadata-summary">Summary</label>
<textarea
id="metadata-summary"
bind:value={metadataForm.summary}
placeholder="Brief description of the GIF"
class="metadata-textarea"
rows="2"
></textarea>
</div>
<div class="metadata-form-group">
<label for="metadata-alt">Alt Text</label>
<textarea
id="metadata-alt"
bind:value={metadataForm.alt}
placeholder="Accessibility description"
class="metadata-textarea"
rows="2"
></textarea>
</div>
<div class="metadata-form-group">
<label for="metadata-dim">Dimensions (dim tag, e.g., 498x280)</label>
<input
id="metadata-dim"
type="text"
bind:value={metadataForm.dim}
placeholder="widthxheight"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="metadata-blurhash">Blurhash</label>
<input
id="metadata-blurhash"
type="text"
bind:value={metadataForm.blurhash}
placeholder="Blurhash string"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="metadata-thumb">Thumbnail URL</label>
<input
id="metadata-thumb"
type="url"
bind:value={metadataForm.thumb}
placeholder="https://..."
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="metadata-image">Image URL</label>
<input
id="metadata-image"
type="url"
bind:value={metadataForm.image}
placeholder="https://..."
class="metadata-input"
/>
</div>
<div class="metadata-form-actions">
<button class="metadata-button cancel" onclick={cancelMetadataForm} disabled={uploading}>
Cancel
</button>
<button class="metadata-button publish" onclick={publishWithMetadata} disabled={uploading}>
{uploading ? 'Publishing...' : 'Publish'}
</button>
</div>
{#if uploadError}
<div class="metadata-error">{uploadError}</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.drawer-backdrop {
position: fixed;
@ -593,4 +1035,199 @@ @@ -593,4 +1035,199 @@
:global(.dark) .gif-upload-error {
color: var(--fog-dark-error, #ef4444);
}
/* Metadata Form Modal */
.metadata-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.metadata-modal {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .metadata-modal {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.metadata-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .metadata-modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.metadata-modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-modal-header h3 {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-modal-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.metadata-modal-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-modal-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .metadata-modal-close:hover {
background: var(--fog-dark-highlight, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-modal-content {
padding: 1.5rem;
}
.metadata-form-group {
margin-bottom: 1rem;
}
.metadata-form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-form-group label {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-input,
.metadata-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: inherit;
}
:global(.dark) .metadata-input,
:global(.dark) .metadata-textarea {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-textarea {
resize: vertical;
min-height: 60px;
}
.metadata-form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .metadata-form-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.metadata-button {
padding: 0.5rem 1.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.metadata-button.cancel {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-button.cancel {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-button.cancel:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.metadata-button.publish {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.metadata-button.publish:hover:not(:disabled) {
opacity: 0.9;
}
.metadata-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.metadata-error {
margin-top: 1rem;
padding: 0.75rem;
background: var(--fog-error-bg, #fee2e2);
border: 1px solid var(--fog-error, #dc2626);
border-radius: 0.375rem;
color: var(--fog-error, #dc2626);
font-size: 0.875rem;
}
:global(.dark) .metadata-error {
background: var(--fog-dark-error-bg, #7f1d1d);
border-color: var(--fog-dark-error, #ef4444);
color: var(--fog-dark-error, #ef4444);
}
</style>

132
src/lib/services/cache/cache-manager.ts vendored

@ -19,49 +19,87 @@ export interface CacheStats { @@ -19,49 +19,87 @@ export interface CacheStats {
* Get statistics about the cache
*/
export async function getCacheStats(): Promise<CacheStats> {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const store = tx.store;
// Retry logic to handle transaction conflicts
const maxRetries = 3;
let lastError: Error | null = null;
const stats: CacheStats = {
totalEvents: 0,
eventsByKind: new Map(),
eventsByPubkey: new Map(),
oldestEvent: null,
newestEvent: null,
totalSize: 0
};
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const store = tx.store;
let count = 0;
for await (const cursor of store.iterate()) {
const event = cursor.value as CachedEvent;
count++;
const stats: CacheStats = {
totalEvents: 0,
eventsByKind: new Map(),
eventsByPubkey: new Map(),
oldestEvent: null,
newestEvent: null,
totalSize: 0
};
// Count by kind
const kindCount = stats.eventsByKind.get(event.kind) || 0;
stats.eventsByKind.set(event.kind, kindCount + 1);
let count = 0;
// Count by pubkey
const pubkeyCount = stats.eventsByPubkey.get(event.pubkey) || 0;
stats.eventsByPubkey.set(event.pubkey, pubkeyCount + 1);
// Process events during transaction - transaction must stay active during iteration
for await (const cursor of store.iterate()) {
const event = cursor.value as CachedEvent;
count++;
// Track oldest/newest
if (stats.oldestEvent === null || event.created_at < stats.oldestEvent) {
stats.oldestEvent = event.created_at;
}
if (stats.newestEvent === null || event.created_at > stats.newestEvent) {
stats.newestEvent = event.created_at;
}
// Count by kind
const kindCount = stats.eventsByKind.get(event.kind) || 0;
stats.eventsByKind.set(event.kind, kindCount + 1);
// Estimate size (rough calculation)
const eventSize = JSON.stringify(event).length;
stats.totalSize += eventSize;
}
// Count by pubkey
const pubkeyCount = stats.eventsByPubkey.get(event.pubkey) || 0;
stats.eventsByPubkey.set(event.pubkey, pubkeyCount + 1);
stats.totalEvents = count;
await tx.done;
// Track oldest/newest
if (stats.oldestEvent === null || event.created_at < stats.oldestEvent) {
stats.oldestEvent = event.created_at;
}
if (stats.newestEvent === null || event.created_at > stats.newestEvent) {
stats.newestEvent = event.created_at;
}
// Estimate size (rough calculation)
const eventSize = JSON.stringify(event).length;
stats.totalSize += eventSize;
}
// Wait for transaction to complete
await tx.done;
stats.totalEvents = count;
return stats;
return stats;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// If it's a transaction error and we have retries left, wait and retry
if (error instanceof DOMException &&
error.message.includes('transaction') &&
attempt < maxRetries - 1) {
// Wait before retrying with exponential backoff
await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
continue;
}
// If it's not a transaction error or we're out of retries, break
break;
}
}
// If we get here, all retries failed
console.error('Error getting cache stats (all retries failed):', lastError);
// Return empty stats on error
return {
totalEvents: 0,
eventsByKind: new Map(),
eventsByPubkey: new Map(),
oldestEvent: null,
newestEvent: null,
totalSize: 0
};
}
/**
@ -148,6 +186,30 @@ export async function clearCacheByKind(kind: number): Promise<number> { @@ -148,6 +186,30 @@ export async function clearCacheByKind(kind: number): Promise<number> {
return deleted;
}
/**
* Clear events by multiple kinds
*/
export async function clearCacheByKinds(kinds: number[]): Promise<number> {
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
const store = tx.store;
let deleted = 0;
const kindSet = new Set(kinds);
// Iterate through all events and delete those matching any of the specified kinds
for await (const cursor of store.iterate()) {
const event = cursor.value as CachedEvent;
if (kindSet.has(event.kind)) {
await cursor.delete();
deleted++;
}
}
await tx.done;
return deleted;
}
/**
* Clear events older than timestamp
*/

26
src/lib/services/nostr/auth-handler.ts

@ -139,6 +139,32 @@ async function loadUserPreferences(pubkey: string): Promise<void> { @@ -139,6 +139,32 @@ async function loadUserPreferences(pubkey: string): Promise<void> {
}
}
/**
* Sign HTTP auth (NIP-98) for authenticated HTTP requests
* Returns Authorization header value: "Nostr <base64-encoded-event>"
*/
export async function signHttpAuth(
url: string,
method: string,
description: string = ''
): Promise<string> {
const event = await sessionManager.signEvent({
kind: KIND.HTTP_AUTH,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: description
});
// Base64 encode the event JSON and return as "Nostr <base64>"
const eventJson = JSON.stringify(event);
const base64 = btoa(eventJson);
return `Nostr ${base64}`;
}
/**
* Sign and publish event
*/

268
src/lib/services/nostr/event-hierarchy.ts

@ -16,65 +16,207 @@ export interface EventHierarchy { @@ -16,65 +16,207 @@ export interface EventHierarchy {
/**
* Build full event hierarchy starting from a given event
* Recursively fetches parent events until reaching root (no references)
* Optimized to batch-fetch all parent events in parallel
*/
export async function buildEventHierarchy(event: NostrEvent): Promise<EventHierarchy> {
const hierarchy: EventHierarchy = {
event,
children: []
};
const eventsMap = new Map<string, NostrEvent>();
const replaceableEventsToFetch = new Map<string, { kind: number; pubkey: string; dTag: string }>();
const maxDepth = 20; // Prevent infinite loops
// Build parent chain
const parent = await findParentEvent(event);
if (parent) {
hierarchy.parent = await buildEventHierarchy(parent);
// Helper to get parent reference from an event
function getParentReference(evt: NostrEvent): { type: 'e' | 'q' | 'a'; value: string } | null {
// Check for e-tag (reply to event) - prioritize this
const eTag = evt.tags.find(t => t[0] === 'e' && t[1]);
if (eTag && eTag[1]) {
return { type: 'e', value: eTag[1] };
}
// Check for q-tag (quoted event)
const qTag = evt.tags.find(t => t[0] === 'q' && t[1]);
if (qTag && qTag[1]) {
return { type: 'q', value: qTag[1] };
}
// Check for a-tag (reply to replaceable event)
const aTag = evt.tags.find(t => t[0] === 'a' && t[1]);
if (aTag && aTag[1]) {
return { type: 'a', value: aTag[1] };
}
return null;
}
return hierarchy;
// Collect all parent IDs we need to fetch (breadth-first)
const eventIdsToFetch = new Set<string>();
const visitedIds = new Set<string>();
function collectParentIds(evt: NostrEvent, depth: number): void {
if (depth > maxDepth || visitedIds.has(evt.id)) {
return;
}
visitedIds.add(evt.id);
const parentRef = getParentReference(evt);
if (!parentRef) {
return; // No parent
}
if (parentRef.type === 'a') {
// Parse a-tag: kind:pubkey:d-tag
const parts = parentRef.value.split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
const key = `${kind}:${pubkey}:${dTag}`;
if (!replaceableEventsToFetch.has(key)) {
replaceableEventsToFetch.set(key, { kind, pubkey, dTag });
}
}
}
} else {
// e-tag or q-tag - add to fetch list
if (!visitedIds.has(parentRef.value)) {
eventIdsToFetch.add(parentRef.value);
}
}
}
// Start collecting from the root event
eventsMap.set(event.id, event);
collectParentIds(event, 0);
// Iteratively fetch events and discover more parents
const relays = relayManager.getProfileReadRelays();
let depth = 0;
while ((eventIdsToFetch.size > 0 || replaceableEventsToFetch.size > 0) && depth < maxDepth) {
// Check cache for event IDs first
const uncachedIds: string[] = [];
for (const eventId of eventIdsToFetch) {
if (eventsMap.has(eventId)) {
continue; // Already have it
}
const cached = await getEvent(eventId);
if (cached) {
eventsMap.set(eventId, cached);
collectParentIds(cached, depth + 1);
} else {
uncachedIds.push(eventId);
}
}
// Batch fetch uncached events
if (uncachedIds.length > 0) {
try {
const fetchedEvents = await nostrClient.fetchEvents(
[{ ids: uncachedIds, limit: uncachedIds.length }],
relays,
{ useCache: true, cacheResults: true }
);
for (const fetchedEvent of fetchedEvents) {
eventsMap.set(fetchedEvent.id, fetchedEvent);
collectParentIds(fetchedEvent, depth + 1);
}
} catch (error) {
console.warn('Error batch fetching events:', error);
}
}
// Fetch replaceable events
const replaceableKeys = Array.from(replaceableEventsToFetch.keys());
for (const key of replaceableKeys) {
const { kind, pubkey, dTag } = replaceableEventsToFetch.get(key)!;
replaceableEventsToFetch.delete(key);
// Check if we already have this event in the map
let found = false;
for (const [id, candidate] of eventsMap.entries()) {
if (candidate.kind === kind && candidate.pubkey === pubkey) {
const candidateDTag = candidate.tags.find(t => t[0] === 'd' && t[1]);
if (candidateDTag && candidateDTag[1] === dTag) {
found = true;
collectParentIds(candidate, depth + 1);
break;
}
}
}
if (!found) {
try {
const fetchedEvents = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (fetchedEvents.length > 0) {
const replaceableEvent = fetchedEvents.sort((a, b) => b.created_at - a.created_at)[0];
eventsMap.set(replaceableEvent.id, replaceableEvent);
collectParentIds(replaceableEvent, depth + 1);
}
} catch (error) {
console.warn('Error fetching replaceable event:', error);
}
}
}
// Remove fetched IDs from the set
for (const id of Array.from(eventIdsToFetch)) {
if (eventsMap.has(id)) {
eventIdsToFetch.delete(id);
}
}
depth++;
}
// Now build the hierarchy from the fetched events
return buildHierarchyFromMap(event, eventsMap);
}
/**
* Get all events in hierarchy as a flat array (root to leaf)
* Build hierarchy structure from events map
*/
export function getHierarchyChain(hierarchy: EventHierarchy): NostrEvent[] {
const chain: NostrEvent[] = [];
// Build chain from root to leaf
let current: EventHierarchy | undefined = hierarchy;
const parents: EventHierarchy[] = [];
function buildHierarchyFromMap(event: NostrEvent, eventsMap: Map<string, NostrEvent>): EventHierarchy {
const hierarchy: EventHierarchy = {
event,
children: []
};
// Collect all parents
while (current) {
parents.unshift(current);
current = current.parent;
// Find parent event
const parent = findParentInMap(event, eventsMap);
if (parent) {
hierarchy.parent = buildHierarchyFromMap(parent, eventsMap);
}
// Return chain from root to leaf
return parents.map(h => h.event);
return hierarchy;
}
/**
* Find parent event by following e-tags, q-tags, or a-tags
* Find parent event in events map
*/
async function findParentEvent(event: NostrEvent): Promise<NostrEvent | null> {
// Check for e-tag (reply to event)
function findParentInMap(event: NostrEvent, eventsMap: Map<string, NostrEvent>): NostrEvent | null {
// Check for e-tag (reply to event) - prioritize this
const eTag = event.tags.find(t => t[0] === 'e' && t[1]);
if (eTag && eTag[1]) {
const parent = await fetchEventById(eTag[1]);
const parent = eventsMap.get(eTag[1]);
if (parent) return parent;
}
// Check for q-tag (quoted event)
const qTag = event.tags.find(t => t[0] === 'q' && t[1]);
if (qTag && qTag[1]) {
const parent = await fetchEventById(qTag[1]);
const parent = eventsMap.get(qTag[1]);
if (parent) return parent;
}
// Check for a-tag (reply to replaceable event)
const aTag = event.tags.find(t => t[0] === 'a' && t[1]);
if (aTag && aTag[1]) {
// Parse a-tag: kind:pubkey:d-tag
const parts = aTag[1].split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0], 10);
@ -82,64 +224,40 @@ async function findParentEvent(event: NostrEvent): Promise<NostrEvent | null> { @@ -82,64 +224,40 @@ async function findParentEvent(event: NostrEvent): Promise<NostrEvent | null> {
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
const parent = await fetchReplaceableEvent(kind, pubkey, dTag);
if (parent) return parent;
// Find replaceable event in map by matching kind, pubkey, and d-tag
for (const candidate of eventsMap.values()) {
if (candidate.kind === kind && candidate.pubkey === pubkey) {
const candidateDTag = candidate.tags.find(t => t[0] === 'd' && t[1]);
if (candidateDTag && candidateDTag[1] === dTag) {
return candidate;
}
}
}
}
}
}
// No parent found
return null;
}
/**
* Fetch event by ID (check cache first, then relays)
* Get all events in hierarchy as a flat array (root to leaf)
*/
async function fetchEventById(eventId: string): Promise<NostrEvent | null> {
// Check cache first
const cached = await getEvent(eventId);
if (cached) {
return cached;
}
// Fetch from relays
try {
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [eventId], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
return events.length > 0 ? events[0] : null;
} catch (error) {
console.warn('Error fetching event by ID:', error);
return null;
}
}
export function getHierarchyChain(hierarchy: EventHierarchy): NostrEvent[] {
const chain: NostrEvent[] = [];
/**
* Fetch replaceable event by kind, pubkey, and d-tag
*/
async function fetchReplaceableEvent(kind: number, pubkey: string, dTag: string): Promise<NostrEvent | null> {
try {
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Return newest (replaceable events can have multiple versions)
if (events.length > 0) {
return events.sort((a, b) => b.created_at - a.created_at)[0];
}
// Build chain from root to leaf
let current: EventHierarchy | undefined = hierarchy;
const parents: EventHierarchy[] = [];
return null;
} catch (error) {
console.warn('Error fetching replaceable event:', error);
return null;
// Collect all parents
while (current) {
parents.unshift(current);
current = current.parent;
}
// Return chain from root to leaf
return parents.map(h => h.event);
}
/**

75
src/lib/services/nostr/gif-service.ts

@ -59,16 +59,24 @@ function parseGifFromEvent(event: NostrEvent): GifMetadata | null { @@ -59,16 +59,24 @@ function parseGifFromEvent(event: NostrEvent): GifMetadata | null {
if (url) break;
}
// Try file tags (NIP-94 kind 1063) - format: ["url", "<URL>"], ["m", "<mime-type>"], etc.
// Try file tags (NIP-94 kind 1063) - format: ["file", "<URL>", "<mime-type>", "size <bytes>", ...]
if (!url) {
const fileTags = event.tags.filter(t => t[0] === 'file' && t[1]);
for (const fileTag of fileTags) {
const candidateUrl = fileTag[1];
if (candidateUrl && candidateUrl.toLowerCase().includes('.gif')) {
const candidateMimeType = fileTag[2]; // MIME type is typically the third element
// Check if it's a GIF by URL extension, mime type, or data URL
const isGifUrl = candidateUrl && (
candidateUrl.toLowerCase().includes('.gif') ||
candidateUrl.toLowerCase().startsWith('data:image/gif') ||
candidateMimeType === 'image/gif'
);
if (isGifUrl) {
url = candidateUrl;
// MIME type is typically the second element
if (fileTag[2]) {
mimeType = fileTag[2];
if (candidateMimeType) {
mimeType = candidateMimeType;
}
break;
}
@ -168,8 +176,9 @@ function parseGifFromEvent(event: NostrEvent): GifMetadata | null { @@ -168,8 +176,9 @@ function parseGifFromEvent(event: NostrEvent): GifMetadata | null {
* Only queries kind 1063 file metadata events to avoid flooding with kind 1 events
* @param searchQuery Optional search query to filter GIFs (searches in content/tags)
* @param limit Maximum number of GIFs to return
* @param forceRefresh If true, skip cache and query relays directly (useful after uploading new GIFs)
*/
export async function fetchGifs(searchQuery?: string, limit: number = 50): Promise<GifMetadata[]> {
export async function fetchGifs(searchQuery?: string, limit: number = 50, forceRefresh: boolean = false): Promise<GifMetadata[]> {
try {
// Ensure client is initialized
await nostrClient.initialize();
@ -199,26 +208,38 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi @@ -199,26 +208,38 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi
console.debug(`[gif-service] Fetching ${fileMetadataKindName} (kind ${fileMetadataKind}) events with filters:`, filters);
// First, try to get cached events for consistent results
let events = await nostrClient.fetchEvents(filters, relays, {
useCache: true, // Use cache first for consistent results
cacheResults: true,
timeout: config.relayTimeout
});
let events: NostrEvent[];
// Then refresh cache in background to get new events
// This ensures we have consistent results from cache while updating it
nostrClient.fetchEvents(filters, relays, {
useCache: false, // Force query relays to update cache
cacheResults: true, // Cache the results
timeout: config.relayTimeout * 2 // Give more time for GIF relays
}).then((newEvents) => {
if (newEvents.length > 0) {
console.debug(`[gif-service] Background refresh cached ${newEvents.length} new ${fileMetadataKindName} events`);
}
}).catch((error) => {
console.debug('[gif-service] Background refresh error:', error);
});
if (forceRefresh) {
// Force refresh: skip cache and query relays directly
console.debug('[gif-service] Force refresh: querying relays directly (skipping cache)');
events = await nostrClient.fetchEvents(filters, relays, {
useCache: false, // Skip cache
cacheResults: true, // Cache the results for next time
timeout: config.relayTimeout * 2 // Give more time for GIF relays
});
} else {
// First, try to get cached events for consistent results
events = await nostrClient.fetchEvents(filters, relays, {
useCache: true, // Use cache first for consistent results
cacheResults: true,
timeout: config.relayTimeout
});
// Then refresh cache in background to get new events
// This ensures we have consistent results from cache while updating it
nostrClient.fetchEvents(filters, relays, {
useCache: false, // Force query relays to update cache
cacheResults: true, // Cache the results
timeout: config.relayTimeout * 2 // Give more time for GIF relays
}).then((newEvents) => {
if (newEvents.length > 0) {
console.debug(`[gif-service] Background refresh cached ${newEvents.length} new ${fileMetadataKindName} events`);
}
}).catch((error) => {
console.debug('[gif-service] Background refresh error:', error);
});
}
// If no cached events, try default relays as fallback
if (events.length === 0) {
@ -329,6 +350,6 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi @@ -329,6 +350,6 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi
/**
* Search GIFs by query
*/
export async function searchGifs(query: string, limit: number = 50): Promise<GifMetadata[]> {
return fetchGifs(query, limit);
export async function searchGifs(query: string, limit: number = 50, forceRefresh: boolean = false): Promise<GifMetadata[]> {
return fetchGifs(query, limit, forceRefresh);
}

56
src/lib/services/nostr/nostr-client.ts

@ -136,17 +136,16 @@ class NostrClient { @@ -136,17 +136,16 @@ class NostrClient {
// Check if this relay has failed too many times - skip permanently for this session
const failureInfo = this.failedRelays.get(url);
if (failureInfo && failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) {
console.debug(`[nostr-client] Relay ${url} has failed ${failureInfo.failureCount} times, skipping for this session`);
throw new Error(`Relay has failed too many times (${failureInfo.failureCount}), skipping for this session`);
// Silently skip - don't throw or log, just return
return;
}
// Check if this relay has failed recently and we should wait
if (failureInfo) {
const timeSinceFailure = Date.now() - failureInfo.lastFailure;
if (timeSinceFailure < failureInfo.retryAfter) {
const waitTime = failureInfo.retryAfter - timeSinceFailure;
console.debug(`[nostr-client] Relay ${url} failed recently, waiting ${Math.round(waitTime / 1000)}s before retry`);
throw new Error(`Relay failed recently, retry after ${Math.round(waitTime / 1000)}s`);
// Still in backoff period, silently skip
return;
}
}
@ -166,7 +165,11 @@ class NostrClient { @@ -166,7 +165,11 @@ class NostrClient {
const ws = (relay as any).ws;
if (ws) {
ws.addEventListener('close', () => {
console.debug(`[nostr-client] Relay ${url} connection closed, removing from active relays`);
// Only log if relay wasn't already marked as permanently failed
const failureInfo = this.failedRelays.get(url);
if (!failureInfo || failureInfo.failureCount < this.PERMANENT_FAILURE_THRESHOLD) {
// Don't log - connection closes are normal
}
this.relays.delete(url);
this.authenticatedRelays.delete(url);
});
@ -181,8 +184,7 @@ class NostrClient { @@ -181,8 +184,7 @@ class NostrClient {
// Clear failure tracking on successful connection
this.failedRelays.delete(url);
// Log successful connection at debug level to reduce console noise
console.debug(`[nostr-client] Successfully connected to relay: ${url}`);
// Don't log successful connections - too verbose
} catch (error) {
// Track the failure but don't throw - allow graceful degradation like jumble
const existingFailure = this.failedRelays.get(url) || { lastFailure: 0, retryAfter: this.INITIAL_RETRY_DELAY, failureCount: 0 };
@ -207,14 +209,16 @@ class NostrClient { @@ -207,14 +209,16 @@ class NostrClient {
failureCount
});
// Only log at debug level to reduce console noise - connection failures are expected
// Only warn if it's a persistent failure (after several attempts)
if (failureCount > 3) {
console.debug(`[nostr-client] Relay ${url} connection failed (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`);
}
// Warn if approaching permanent failure threshold
// Only log warnings for persistent failures to reduce console noise
// Connection failures are expected and normal, so we don't log every attempt
if (failureCount >= this.PERMANENT_FAILURE_THRESHOLD) {
console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will be skipped for this session`);
// Only log once when threshold is reached
if (failureCount === this.PERMANENT_FAILURE_THRESHOLD) {
console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will be skipped for this session`);
}
} else if (failureCount === 10) {
// Log once at 10 failures as a warning
console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will stop retrying after ${this.PERMANENT_FAILURE_THRESHOLD} failures`);
}
// Don't throw - allow graceful degradation like jumble does
// The caller can check if relay was added by checking this.relays.has(url)
@ -243,8 +247,7 @@ class NostrClient { @@ -243,8 +247,7 @@ class NostrClient {
// Status values: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED
const status = (relay as any).status;
if (status === 3 || status === 2) {
// Relay is closed or closing, remove it
console.debug(`[nostr-client] Relay ${relayUrl} is closed (status: ${status}), removing from active relays`);
// Relay is closed or closing, remove it silently
this.relays.delete(relayUrl);
this.authenticatedRelays.delete(relayUrl);
return false;
@ -596,16 +599,27 @@ class NostrClient { @@ -596,16 +599,27 @@ class NostrClient {
// Check if relay should be skipped before attempting connection
const failureInfo = this.failedRelays.get(url);
if (failureInfo && failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) {
console.debug(`[nostr-client] Skipping permanently failed relay ${url} for subscription`);
// Skip permanently failed relays silently
continue;
}
// Check if relay failed recently and is still in backoff period
if (failureInfo) {
const timeSinceFailure = Date.now() - failureInfo.lastFailure;
if (timeSinceFailure < failureInfo.retryAfter) {
// Still in backoff period, skip this attempt
continue;
}
}
// addRelay doesn't throw on failure, it just doesn't add the relay (graceful degradation like jumble)
this.addRelay(url).then(() => {
const newRelay = this.relays.get(url);
if (newRelay) {
this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose);
}
}).catch(() => {
// Ignore errors - addRelay handles failures gracefully
});
continue;
}
@ -615,7 +629,7 @@ class NostrClient { @@ -615,7 +629,7 @@ class NostrClient {
// Check relay status before setting up subscription
if (!this.checkAndCleanupRelay(url)) {
console.debug(`[nostr-client] Relay ${url} is closed, skipping subscription`);
// Relay is closed, skip silently
continue;
}
@ -942,10 +956,8 @@ class NostrClient { @@ -942,10 +956,8 @@ class NostrClient {
}
}
return cachedEvents;
} else {
// No cached events - this is expected and normal, so use debug level
console.debug(`[nostr-client] No cached events found for filter:`, filters);
}
// No cached events - this is expected and normal, so don't log it
} catch (error) {
console.error('[nostr-client] Error querying cache:', error);
// Continue to fetch from relays

3
src/lib/types/kind-lookup.ts

@ -89,7 +89,8 @@ export const KIND = { @@ -89,7 +89,8 @@ export const KIND = {
EMOJI_PACK: 30030,
MUTE_LIST: 10000,
BADGES: 30008,
FOLOW_SET: 30000
FOLOW_SET: 30000,
HTTP_AUTH: 27235 // NIP-98 HTTP Auth (matches nostr-tools and jumble)
} as const;
export const KIND_LOOKUP: Record<number, KindInfo> = {

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

@ -2,9 +2,13 @@ @@ -2,9 +2,13 @@
import Header from '../../lib/components/layout/Header.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js';
import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js';
import type { CachedEvent } from '../../lib/services/cache/event-cache.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { signAndPublish } from '../../lib/services/nostr/auth-handler.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
let stats = $state<CacheStats | null>(null);
let events = $state<CachedEvent[]>([]);
@ -103,7 +107,9 @@ @@ -103,7 +107,9 @@
try {
await clearAllCache();
events = [];
expandedEvents.clear();
await loadStats();
await loadEvents(true);
alert('Cache cleared successfully');
} catch (error) {
console.error('Error clearing cache:', error);
@ -119,8 +125,11 @@ @@ -119,8 +125,11 @@
try {
const deleted = await clearCacheByKind(kind);
// Remove deleted events from expanded set
events.filter(e => e.kind === kind).forEach(e => expandedEvents.delete(e.id));
events = events.filter(e => e.kind !== kind);
await loadStats();
await loadEvents(true);
alert(`Deleted ${deleted} events`);
} catch (error) {
console.error('Error clearing cache by kind:', error);
@ -136,8 +145,11 @@ @@ -136,8 +145,11 @@
try {
const olderThan = Math.floor(Date.now() / 1000) - (days * 86400);
const deleted = await clearCacheByDate(olderThan);
// Remove deleted events from expanded set
events.filter(e => e.created_at < olderThan).forEach(e => expandedEvents.delete(e.id));
events = events.filter(e => e.created_at >= olderThan);
await loadStats();
await loadEvents(true);
alert(`Deleted ${deleted} events`);
} catch (error) {
console.error('Error clearing cache by date:', error);
@ -145,6 +157,26 @@ @@ -145,6 +157,26 @@
}
}
async function handleClearShortTextNotes() {
const kinds: number[] = [KIND.SHORT_TEXT_NOTE, KIND.REACTION, KIND.COMMENT];
if (!confirm(`Are you sure you want to clear all short text notes (1), reactions (7), and comments (1111) from cache?`)) {
return;
}
try {
const deleted = await clearCacheByKinds(kinds);
// Remove deleted events from expanded set
events.filter(e => kinds.includes(e.kind)).forEach(e => expandedEvents.delete(e.id));
events = events.filter(e => !kinds.includes(e.kind));
await loadStats();
await loadEvents(true);
alert(`Deleted ${deleted} events`);
} catch (error) {
console.error('Error clearing cache by kinds:', error);
alert('Failed to clear cache');
}
}
function toggleExpand(eventId: string) {
if (expandedEvents.has(eventId)) {
expandedEvents.delete(eventId);
@ -178,16 +210,201 @@ @@ -178,16 +210,201 @@
}
function getKindName(kind: number): string {
const kindNames: Record<number, string> = {
[KIND.METADATA]: 'Metadata',
[KIND.SHORT_TEXT_NOTE]: 'Short Text Note',
[KIND.REACTION]: 'Reaction',
[KIND.DISCUSSION_THREAD]: 'Discussion Thread',
[KIND.COMMENT]: 'Comment',
};
return kindNames[kind] || `Kind ${kind}`;
return getKindInfo(kind).description;
}
/**
* Decode bech32 pubkey (npub or nprofile) to hex
*/
function decodePubkeyToHex(input: string): string {
if (!input || input.trim() === '') return '';
const trimmed = input.trim();
// If it's already hex (64 chars), return as-is
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
// Try to decode bech32
try {
if (trimmed.startsWith('npub')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
return decoded.data;
}
} else if (trimmed.startsWith('nprofile')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'nprofile') {
return decoded.data.pubkey;
}
}
} catch (e) {
// Not a valid bech32, return as-is for search
}
return trimmed;
}
/**
* Decode bech32 event ID (nevent, naddr, or note) to hex
*/
function decodeEventIdToHex(input: string): string {
if (!input || input.trim() === '') return '';
const trimmed = input.trim();
// If it's already hex (64 chars), return as-is
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
// Try to decode bech32
try {
if (trimmed.startsWith('nevent')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'nevent') {
return decoded.data.id;
}
} else if (trimmed.startsWith('naddr')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'naddr') {
// naddr doesn't have an event ID, return empty
return '';
}
} else if (trimmed.startsWith('note')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'note') {
return decoded.data;
}
}
} catch (e) {
// Not a valid bech32, return as-is for search
}
return trimmed;
}
/**
* Get npub from hex pubkey
*/
function getNpubFromHex(hex: string): string {
try {
return nip19.npubEncode(hex);
} catch {
return '';
}
}
/**
* Handle pubkey filter input - decode bech32 to hex
*/
function handlePubkeyFilterInput(value: string) {
selectedPubkey = value;
const timeout = setTimeout(() => {
const hexPubkey = decodePubkeyToHex(value);
if (hexPubkey !== value) {
selectedPubkey = hexPubkey;
}
handleFilterChange();
}, 500);
return () => clearTimeout(timeout);
}
/**
* Handle event search input - decode bech32 to hex
*/
function handleEventSearchInput(value: string) {
searchTerm = value;
const timeout = setTimeout(() => {
const hexId = decodeEventIdToHex(value);
if (hexId !== value && hexId) {
searchTerm = hexId;
}
handleFilterChange();
}, 500);
return () => clearTimeout(timeout);
}
/**
* Handle kind click - filter by that kind
*/
function handleKindClick(kind: number) {
selectedKind = kind;
handleFilterChange();
}
/**
* Publish delete request (NIP-09) for own events
*/
async function handlePublishDeleteRequest(event: CachedEvent) {
if (!confirm('Are you sure you want to publish a delete request for this event? This will notify relays to delete it and remove it from cache.')) {
return;
}
deletingEventId = event.id;
try {
const deleteEvent: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.EVENT_DELETION,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', event.id]],
content: ''
};
const result = await signAndPublish(deleteEvent);
if (result.success.length > 0) {
// Also delete from cache
try {
await deleteEventById(event.id);
events = events.filter(e => e.id !== event.id);
expandedEvents.delete(event.id);
// Wait longer for the delete transaction to fully complete before reloading stats
// IndexedDB transactions need time to commit
await new Promise(resolve => setTimeout(resolve, 500));
// Retry loading stats with exponential backoff
let retries = 3;
let lastError: Error | null = null;
while (retries > 0) {
try {
await loadStats();
break; // Success, exit retry loop
} catch (statsError) {
lastError = statsError instanceof Error ? statsError : new Error(String(statsError));
retries--;
if (retries > 0) {
// Wait before retry with exponential backoff
await new Promise(resolve => setTimeout(resolve, 300 * (4 - retries)));
}
}
}
if (retries === 0 && lastError) {
console.error('Error reloading stats after delete (all retries failed):', lastError);
// Don't show error to user - stats will update on next manual refresh
}
alert(`Delete request published to ${result.success.length} relay(s) and event removed from cache`);
} catch (deleteError) {
console.error('Error deleting from cache:', deleteError);
alert(`Delete request published to ${result.success.length} relay(s), but failed to remove from cache: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`);
}
} else {
alert('Failed to publish delete request');
}
} catch (error) {
console.error('Error publishing delete request:', error);
alert('Failed to publish delete request');
} finally {
deletingEventId = null;
}
}
// Get current user pubkey
let currentUserPubkey = $derived(sessionManager.getCurrentPubkey());
function getKindOptions(): number[] {
if (!stats) return [];
return Array.from(stats.eventsByKind.keys()).sort((a, b) => a - b);
@ -233,12 +450,12 @@ @@ -233,12 +450,12 @@
<h3 class="subsection-title">Events by Kind</h3>
<div class="kind-list">
{#each Array.from(stats.eventsByKind.entries()).sort((a, b) => b[1] - a[1]) as [kind, count]}
<div class="kind-item">
<div class="kind-item" onclick={() => handleKindClick(kind)} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleKindClick(kind)}>
<span class="kind-name">{getKindName(kind)} ({kind})</span>
<span class="kind-count">{count.toLocaleString()}</span>
<button
class="clear-kind-button"
onclick={() => handleClearByKind(kind)}
onclick={(e) => { e.stopPropagation(); handleClearByKind(kind); }}
title="Clear all events of this kind"
>
Clear
@ -270,32 +487,25 @@ @@ -270,32 +487,25 @@
</div>
<div class="filter-group">
<label for="pubkey-filter" class="filter-label">Pubkey</label>
<label for="pubkey-filter" class="filter-label">Pubkey (hex, npub, or nprofile)</label>
<input
id="pubkey-filter"
type="text"
bind:value={selectedPubkey}
oninput={() => {
// Debounce search
const timeout = setTimeout(() => handleFilterChange(), 500);
return () => clearTimeout(timeout);
}}
placeholder="Filter by pubkey..."
oninput={(e) => handlePubkeyFilterInput(e.currentTarget.value)}
placeholder="Filter by pubkey (hex, npub, or nprofile)..."
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="search-filter" class="filter-label">Search</label>
<label for="search-filter" class="filter-label">Search (hex ID, naddr, nevent, or note)</label>
<input
id="search-filter"
type="text"
bind:value={searchTerm}
oninput={() => {
const timeout = setTimeout(() => handleFilterChange(), 500);
return () => clearTimeout(timeout);
}}
placeholder="Search event ID or content..."
oninput={(e) => handleEventSearchInput(e.currentTarget.value)}
placeholder="Search event ID (hex, naddr, nevent, note) or content..."
class="filter-input"
/>
</div>
@ -309,6 +519,9 @@ @@ -309,6 +519,9 @@
<button class="bulk-action-button" onclick={handleClearAll}>
Clear All Cache
</button>
<button class="bulk-action-button" onclick={handleClearShortTextNotes}>
Clear Short Text Notes, Reactions & Comments
</button>
<button class="bulk-action-button" onclick={() => handleClearByDate(30)}>
Clear Events Older Than 30 Days
</button>
@ -334,37 +547,22 @@ @@ -334,37 +547,22 @@
<div class="events-list">
{#each events as event (event.id)}
<div class="event-card">
<button
class="copy-button-top"
onclick={() => copyEventJson(event)}
title="Copy event JSON"
>
📋
</button>
<div class="event-header">
<div class="event-info">
<div class="event-id">
<strong>ID:</strong>
<code class="event-id-code">{event.id.substring(0, 16)}...</code>
<a href="/event/{event.id}" class="event-link" target="_blank">View</a>
</div>
<div class="event-meta">
<span><strong>Kind:</strong> {getKindName(event.kind)} ({event.kind})</span>
<span><strong>Pubkey:</strong> <code>{event.pubkey.substring(0, 16)}...</code></span>
{#if getNpubFromHex(event.pubkey)}
<span><strong>npub:</strong> <code>{getNpubFromHex(event.pubkey)}</code></span>
{/if}
<span><strong>Created:</strong> {formatDate(event.created_at)}</span>
<span><strong>Cached:</strong> {formatDate(event.cached_at / 1000)}</span>
</div>
</div>
<div class="event-actions">
<button
class="delete-button"
onclick={() => handleDeleteEvent(event.id)}
disabled={deletingEventId === event.id}
title="Delete from cache"
>
{deletingEventId === event.id ? 'Deleting...' : '🗑 Delete'}
</button>
</div>
</div>
{#if expandedEvents.has(event.id)}
@ -372,16 +570,62 @@ @@ -372,16 +570,62 @@
<div class="event-json" contenteditable="true" spellcheck="false">
{JSON.stringify(event, null, 2)}
</div>
<button class="collapse-button" onclick={() => toggleExpand(event.id)}>
Collapse
</button>
<div class="event-actions-bottom">
<a href="/event/{event.id}" class="action-button" target="_blank">View</a>
<button class="action-button" onclick={() => copyEventJson(event)} title="Copy event JSON">
Copy JSON
</button>
<button
class="action-button delete-action"
onclick={() => handleDeleteEvent(event.id)}
disabled={deletingEventId === event.id}
title="Delete from cache"
>
{deletingEventId === event.id ? 'Deleting...' : 'Delete from cache'}
</button>
{#if currentUserPubkey === event.pubkey}
<button
class="action-button delete-request-action"
onclick={() => handlePublishDeleteRequest(event)}
title="Publish delete request (NIP-09)"
>
Publish delete request
</button>
{/if}
<button class="action-button" onclick={() => toggleExpand(event.id)}>
Collapse
</button>
</div>
</div>
{:else}
<div class="event-preview">
<p class="event-content-preview">{event.content.substring(0, 200)}{event.content.length > 200 ? '...' : ''}</p>
<button class="expand-button" onclick={() => toggleExpand(event.id)}>
Expand JSON
</button>
<div class="event-actions-bottom">
<a href="/event/{event.id}" class="action-button" target="_blank">View</a>
<button class="action-button" onclick={() => copyEventJson(event)} title="Copy event JSON">
Copy JSON
</button>
<button
class="action-button delete-action"
onclick={() => handleDeleteEvent(event.id)}
disabled={deletingEventId === event.id}
title="Delete from cache"
>
{deletingEventId === event.id ? 'Deleting...' : 'Delete from cache'}
</button>
{#if currentUserPubkey === event.pubkey}
<button
class="action-button delete-request-action"
onclick={() => handlePublishDeleteRequest(event)}
title="Publish delete request (NIP-09)"
>
Publish delete request
</button>
{/if}
<button class="action-button" onclick={() => toggleExpand(event.id)}>
Expand JSON
</button>
</div>
</div>
{/if}
</div>
@ -521,6 +765,18 @@ @@ -521,6 +765,18 @@
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.2s;
}
.kind-item:hover {
background: var(--fog-accent, #64748b);
color: white;
}
.kind-item:hover .kind-name,
.kind-item:hover .kind-count {
color: white;
}
:global(.dark) .kind-item {
@ -632,34 +888,13 @@ @@ -632,34 +888,13 @@
background: var(--fog-post, #ffffff);
padding: 1rem;
position: relative;
overflow: hidden;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
box-sizing: border-box;
}
.copy-button-top {
position: absolute;
top: 0.75rem;
right: 0.75rem;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
z-index: 10;
}
:global(.dark) .copy-button-top {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.copy-button-top:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .event-card {
background: var(--fog-dark-post, #334155);
@ -682,6 +917,9 @@ @@ -682,6 +917,9 @@
.event-id {
margin-bottom: 0.5rem;
color: var(--fog-text, #1f2937);
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
:global(.dark) .event-id {
@ -695,29 +933,24 @@ @@ -695,29 +933,24 @@
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
margin: 0 0.5rem;
word-break: break-all;
overflow-wrap: break-word;
max-width: 100%;
display: inline-block;
}
:global(.dark) .event-id-code {
background: var(--fog-dark-highlight, #475569);
}
.event-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
margin-left: 0.5rem;
font-size: 0.875rem;
}
.event-link:hover {
text-decoration: underline;
}
.event-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
word-wrap: break-word;
overflow-wrap: break-word;
}
:global(.dark) .event-meta {
@ -727,53 +960,31 @@ @@ -727,53 +960,31 @@
.event-meta code {
font-family: monospace;
font-size: 0.8125rem;
word-break: break-all;
overflow-wrap: break-word;
max-width: 100%;
}
.event-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.delete-button {
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
:global(.dark) .copy-button,
:global(.dark) .delete-button {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.copy-button:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.delete-button:hover {
background: #ef4444;
color: white;
border-color: #ef4444;
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
.event-meta span {
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
.event-content {
margin-top: 0.75rem;
}
.event-actions-bottom {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
align-items: center;
max-width: 100%;
overflow: hidden;
}
.event-json {
font-family: monospace;
font-size: 0.8125rem;
@ -783,13 +994,17 @@ @@ -783,13 +994,17 @@
padding: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-all;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
color: var(--fog-text, #1f2937);
max-width: 100%;
}
:global(.dark) .event-json {
@ -806,14 +1021,16 @@ @@ -806,14 +1021,16 @@
margin: 0 0 0.5rem 0;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
:global(.dark) .event-content-preview {
color: var(--fog-dark-text-light, #9ca3af);
}
.expand-button,
.collapse-button {
.action-button {
padding: 0.5rem 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
@ -821,22 +1038,47 @@ @@ -821,22 +1038,47 @@
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
text-decoration: none;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
:global(.dark) .expand-button,
:global(.dark) .collapse-button {
:global(.dark) .action-button {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.expand-button:hover,
.collapse-button:hover {
.action-button:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.action-button.delete-action:hover {
background: #ef4444;
border-color: #ef4444;
}
.action-button.delete-request-action {
background: #f59e0b;
border-color: #f59e0b;
color: white;
}
.action-button.delete-request-action:hover {
background: #d97706;
border-color: #d97706;
}
.action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.load-more-section {
text-align: center;
margin-top: 2rem;

3
src/routes/rss/[pubkey]/+page.server.ts → src/routes/rss/[pubkey]/+server.ts

@ -1,12 +1,9 @@ @@ -1,12 +1,9 @@
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { getEventsByPubkey } from '../../../lib/services/cache/event-cache.js';
import { stripMarkdown } from '../../../lib/services/text-utils.js';
import type { RequestHandler } from '@sveltejs/kit';
import { KIND } from '../../../lib/types/kind-lookup.js';
const RSS_FEED_KIND = 10015;
export const GET: RequestHandler = async ({ params, url }) => {
const pubkey = params.pubkey;
Loading…
Cancel
Save